1mod conflict_set_tests {
2 use std::sync::mpsc;
3
4 use crate::Project;
5
6 use fs::FakeFs;
7 use git::{
8 repository::{RepoPath, repo_path},
9 status::{UnmergedStatus, UnmergedStatusCode},
10 };
11 use gpui::{BackgroundExecutor, TestAppContext};
12 use project::git_store::*;
13 use serde_json::json;
14 use text::{Buffer, BufferId, OffsetRangeExt, Point, ReplicaId, ToOffset as _};
15 use unindent::Unindent as _;
16 use util::{path, rel_path::rel_path};
17
18 #[test]
19 fn test_parse_conflicts_in_buffer() {
20 // Create a buffer with conflict markers
21 let test_content = r#"
22 This is some text before the conflict.
23 <<<<<<< HEAD
24 This is our version
25 =======
26 This is their version
27 >>>>>>> branch-name
28
29 Another conflict:
30 <<<<<<< HEAD
31 Our second change
32 ||||||| merged common ancestors
33 Original content
34 =======
35 Their second change
36 >>>>>>> branch-name
37 "#
38 .unindent();
39
40 let buffer_id = BufferId::new(1).unwrap();
41 let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content);
42 let snapshot = buffer.snapshot();
43
44 let conflict_snapshot = ConflictSet::parse(&snapshot);
45 assert_eq!(conflict_snapshot.conflicts.len(), 2);
46
47 let first = &conflict_snapshot.conflicts[0];
48 assert!(first.base.is_none());
49 assert_eq!(first.ours_branch_name.as_ref(), "HEAD");
50 assert_eq!(first.theirs_branch_name.as_ref(), "branch-name");
51 let our_text = snapshot
52 .text_for_range(first.ours.clone())
53 .collect::<String>();
54 let their_text = snapshot
55 .text_for_range(first.theirs.clone())
56 .collect::<String>();
57 assert_eq!(our_text, "This is our version\n");
58 assert_eq!(their_text, "This is their version\n");
59
60 let second = &conflict_snapshot.conflicts[1];
61 assert!(second.base.is_some());
62 assert_eq!(second.ours_branch_name.as_ref(), "HEAD");
63 assert_eq!(second.theirs_branch_name.as_ref(), "branch-name");
64 let our_text = snapshot
65 .text_for_range(second.ours.clone())
66 .collect::<String>();
67 let their_text = snapshot
68 .text_for_range(second.theirs.clone())
69 .collect::<String>();
70 let base_text = snapshot
71 .text_for_range(second.base.as_ref().unwrap().clone())
72 .collect::<String>();
73 assert_eq!(our_text, "Our second change\n");
74 assert_eq!(their_text, "Their second change\n");
75 assert_eq!(base_text, "Original content\n");
76
77 // Test conflicts_in_range
78 let range = snapshot.anchor_before(0)..snapshot.anchor_before(snapshot.len());
79 let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
80 assert_eq!(conflicts_in_range.len(), 2);
81
82 // Test with a range that includes only the first conflict
83 let first_conflict_end = conflict_snapshot.conflicts[0].range.end;
84 let range = snapshot.anchor_before(0)..first_conflict_end;
85 let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
86 assert_eq!(conflicts_in_range.len(), 1);
87
88 // Test with a range that includes only the second conflict
89 let second_conflict_start = conflict_snapshot.conflicts[1].range.start;
90 let range = second_conflict_start..snapshot.anchor_before(snapshot.len());
91 let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
92 assert_eq!(conflicts_in_range.len(), 1);
93
94 // Test with a range that doesn't include any conflicts
95 let range = buffer.anchor_after(first_conflict_end.to_next_offset(&buffer))
96 ..buffer.anchor_before(second_conflict_start.to_previous_offset(&buffer));
97 let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
98 assert_eq!(conflicts_in_range.len(), 0);
99 }
100
101 #[test]
102 fn test_nested_conflict_markers() {
103 // Create a buffer with nested conflict markers
104 let test_content = r#"
105 This is some text before the conflict.
106 <<<<<<< HEAD
107 This is our version
108 <<<<<<< HEAD
109 This is a nested conflict marker
110 =======
111 This is their version in a nested conflict
112 >>>>>>> branch-nested
113 =======
114 This is their version
115 >>>>>>> branch-name
116 "#
117 .unindent();
118
119 let buffer_id = BufferId::new(1).unwrap();
120 let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content);
121 let snapshot = buffer.snapshot();
122
123 let conflict_snapshot = ConflictSet::parse(&snapshot);
124
125 assert_eq!(conflict_snapshot.conflicts.len(), 1);
126
127 // The conflict should have our version, their version, but no base
128 let conflict = &conflict_snapshot.conflicts[0];
129 assert!(conflict.base.is_none());
130 assert_eq!(conflict.ours_branch_name.as_ref(), "HEAD");
131 assert_eq!(conflict.theirs_branch_name.as_ref(), "branch-nested");
132
133 // Check that the nested conflict was detected correctly
134 let our_text = snapshot
135 .text_for_range(conflict.ours.clone())
136 .collect::<String>();
137 assert_eq!(our_text, "This is a nested conflict marker\n");
138 let their_text = snapshot
139 .text_for_range(conflict.theirs.clone())
140 .collect::<String>();
141 assert_eq!(their_text, "This is their version in a nested conflict\n");
142 }
143
144 #[test]
145 fn test_conflict_markers_at_eof() {
146 let test_content = r#"
147 <<<<<<< ours
148 =======
149 This is their version
150 >>>>>>> "#
151 .unindent();
152 let buffer_id = BufferId::new(1).unwrap();
153 let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content);
154 let snapshot = buffer.snapshot();
155
156 let conflict_snapshot = ConflictSet::parse(&snapshot);
157 assert_eq!(conflict_snapshot.conflicts.len(), 1);
158 assert_eq!(
159 conflict_snapshot.conflicts[0].ours_branch_name.as_ref(),
160 "ours"
161 );
162 assert_eq!(
163 conflict_snapshot.conflicts[0].theirs_branch_name.as_ref(),
164 "Origin" // default branch name if there is none
165 );
166 }
167
168 #[test]
169 fn test_conflicts_in_range() {
170 // Create a buffer with conflict markers
171 let test_content = r#"
172 one
173 <<<<<<< HEAD1
174 two
175 =======
176 three
177 >>>>>>> branch1
178 four
179 five
180 <<<<<<< HEAD2
181 six
182 =======
183 seven
184 >>>>>>> branch2
185 eight
186 nine
187 <<<<<<< HEAD3
188 ten
189 =======
190 eleven
191 >>>>>>> branch3
192 twelve
193 <<<<<<< HEAD4
194 thirteen
195 =======
196 fourteen
197 >>>>>>> branch4
198 fifteen
199 "#
200 .unindent();
201
202 let buffer_id = BufferId::new(1).unwrap();
203 let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content.clone());
204 let snapshot = buffer.snapshot();
205
206 let conflict_snapshot = ConflictSet::parse(&snapshot);
207 assert_eq!(conflict_snapshot.conflicts.len(), 4);
208 assert_eq!(
209 conflict_snapshot.conflicts[0].ours_branch_name.as_ref(),
210 "HEAD1"
211 );
212 assert_eq!(
213 conflict_snapshot.conflicts[0].theirs_branch_name.as_ref(),
214 "branch1"
215 );
216 assert_eq!(
217 conflict_snapshot.conflicts[1].ours_branch_name.as_ref(),
218 "HEAD2"
219 );
220 assert_eq!(
221 conflict_snapshot.conflicts[1].theirs_branch_name.as_ref(),
222 "branch2"
223 );
224 assert_eq!(
225 conflict_snapshot.conflicts[2].ours_branch_name.as_ref(),
226 "HEAD3"
227 );
228 assert_eq!(
229 conflict_snapshot.conflicts[2].theirs_branch_name.as_ref(),
230 "branch3"
231 );
232 assert_eq!(
233 conflict_snapshot.conflicts[3].ours_branch_name.as_ref(),
234 "HEAD4"
235 );
236 assert_eq!(
237 conflict_snapshot.conflicts[3].theirs_branch_name.as_ref(),
238 "branch4"
239 );
240
241 let range = test_content.find("seven").unwrap()..test_content.find("eleven").unwrap();
242 let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
243 assert_eq!(
244 conflict_snapshot.conflicts_in_range(range, &snapshot),
245 &conflict_snapshot.conflicts[1..=2]
246 );
247
248 let range = test_content.find("one").unwrap()..test_content.find("<<<<<<< HEAD2").unwrap();
249 let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
250 assert_eq!(
251 conflict_snapshot.conflicts_in_range(range, &snapshot),
252 &conflict_snapshot.conflicts[0..=1]
253 );
254
255 let range =
256 test_content.find("eight").unwrap() - 1..test_content.find(">>>>>>> branch3").unwrap();
257 let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
258 assert_eq!(
259 conflict_snapshot.conflicts_in_range(range, &snapshot),
260 &conflict_snapshot.conflicts[1..=2]
261 );
262
263 let range = test_content.find("thirteen").unwrap() - 1..test_content.len();
264 let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
265 assert_eq!(
266 conflict_snapshot.conflicts_in_range(range, &snapshot),
267 &conflict_snapshot.conflicts[3..=3]
268 );
269 }
270
271 #[gpui::test]
272 async fn test_conflict_updates(executor: BackgroundExecutor, cx: &mut TestAppContext) {
273 zlog::init_test();
274 cx.update(|cx| {
275 settings::init(cx);
276 });
277 let initial_text = "
278 one
279 two
280 three
281 four
282 five
283 "
284 .unindent();
285 let fs = FakeFs::new(executor);
286 fs.insert_tree(
287 path!("/project"),
288 json!({
289 ".git": {},
290 "a.txt": initial_text,
291 }),
292 )
293 .await;
294 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
295 let (git_store, buffer) = project.update(cx, |project, cx| {
296 (
297 project.git_store().clone(),
298 project.open_local_buffer(path!("/project/a.txt"), cx),
299 )
300 });
301 let buffer = buffer.await.unwrap();
302 let conflict_set = git_store.update(cx, |git_store, cx| {
303 git_store.open_conflict_set(buffer.clone(), cx)
304 });
305 let (events_tx, events_rx) = mpsc::channel::<ConflictSetUpdate>();
306 let _conflict_set_subscription = cx.update(|cx| {
307 cx.subscribe(&conflict_set, move |_, event, _| {
308 events_tx.send(event.clone()).ok();
309 })
310 });
311 let conflicts_snapshot =
312 conflict_set.read_with(cx, |conflict_set, _| conflict_set.snapshot());
313 assert!(conflicts_snapshot.conflicts.is_empty());
314
315 buffer.update(cx, |buffer, cx| {
316 buffer.edit(
317 [
318 (4..4, "<<<<<<< HEAD\n"),
319 (14..14, "=======\nTWO\n>>>>>>> branch\n"),
320 ],
321 None,
322 cx,
323 );
324 });
325
326 cx.run_until_parked();
327 events_rx.try_recv().expect_err(
328 "no conflicts should be registered as long as the file's status is unchanged",
329 );
330
331 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
332 state.unmerged_paths.insert(
333 repo_path("a.txt"),
334 UnmergedStatus {
335 first_head: UnmergedStatusCode::Updated,
336 second_head: UnmergedStatusCode::Updated,
337 },
338 );
339 // Cause the repository to emit MergeHeadsChanged.
340 state.refs.insert("MERGE_HEAD".into(), "123".into())
341 })
342 .unwrap();
343
344 cx.run_until_parked();
345 let update = events_rx
346 .try_recv()
347 .expect("status change should trigger conflict parsing");
348 assert_eq!(update.old_range, 0..0);
349 assert_eq!(update.new_range, 0..1);
350
351 let conflict = conflict_set.read_with(cx, |conflict_set, _| {
352 conflict_set.snapshot().conflicts[0].clone()
353 });
354 cx.update(|cx| {
355 conflict.resolve(buffer.clone(), std::slice::from_ref(&conflict.theirs), cx);
356 });
357
358 cx.run_until_parked();
359 let update = events_rx
360 .try_recv()
361 .expect("conflicts should be removed after resolution");
362 assert_eq!(update.old_range, 0..1);
363 assert_eq!(update.new_range, 0..0);
364 }
365
366 #[gpui::test]
367 async fn test_conflict_updates_without_merge_head(
368 executor: BackgroundExecutor,
369 cx: &mut TestAppContext,
370 ) {
371 zlog::init_test();
372 cx.update(|cx| {
373 settings::init(cx);
374 });
375
376 let initial_text = "
377 zero
378 <<<<<<< HEAD
379 one
380 =======
381 two
382 >>>>>>> Stashed Changes
383 three
384 "
385 .unindent();
386
387 let fs = FakeFs::new(executor);
388 fs.insert_tree(
389 path!("/project"),
390 json!({
391 ".git": {},
392 "a.txt": initial_text,
393 }),
394 )
395 .await;
396
397 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
398 let (git_store, buffer) = project.update(cx, |project, cx| {
399 (
400 project.git_store().clone(),
401 project.open_local_buffer(path!("/project/a.txt"), cx),
402 )
403 });
404
405 cx.run_until_parked();
406 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
407 state.unmerged_paths.insert(
408 RepoPath::from_rel_path(rel_path("a.txt")),
409 UnmergedStatus {
410 first_head: UnmergedStatusCode::Updated,
411 second_head: UnmergedStatusCode::Updated,
412 },
413 )
414 })
415 .unwrap();
416
417 let buffer = buffer.await.unwrap();
418
419 // Open the conflict set for a file that currently has conflicts.
420 let conflict_set = git_store.update(cx, |git_store, cx| {
421 git_store.open_conflict_set(buffer.clone(), cx)
422 });
423
424 cx.run_until_parked();
425 conflict_set.update(cx, |conflict_set, cx| {
426 let conflict_range = conflict_set.snapshot().conflicts[0]
427 .range
428 .to_point(buffer.read(cx));
429 assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
430 });
431
432 // Simulate the conflict being removed by e.g. staging the file.
433 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
434 state.unmerged_paths.remove(&repo_path("a.txt"))
435 })
436 .unwrap();
437
438 cx.run_until_parked();
439 conflict_set.update(cx, |conflict_set, _| {
440 assert!(!conflict_set.has_conflict);
441 assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
442 });
443
444 // Simulate the conflict being re-added.
445 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
446 state.unmerged_paths.insert(
447 repo_path("a.txt"),
448 UnmergedStatus {
449 first_head: UnmergedStatusCode::Updated,
450 second_head: UnmergedStatusCode::Updated,
451 },
452 )
453 })
454 .unwrap();
455
456 cx.run_until_parked();
457 conflict_set.update(cx, |conflict_set, cx| {
458 let conflict_range = conflict_set.snapshot().conflicts[0]
459 .range
460 .to_point(buffer.read(cx));
461 assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
462 });
463 }
464}
465
466mod git_traversal {
467 use std::{path::Path, time::Duration};
468
469 use collections::HashMap;
470 use project::{
471 Project,
472 git_store::{RepositoryId, RepositorySnapshot},
473 };
474
475 use fs::FakeFs;
476 use git::status::{
477 FileStatus, GitSummary, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode,
478 };
479 use gpui::TestAppContext;
480 use project::GitTraversal;
481
482 use serde_json::json;
483 use settings::SettingsStore;
484 use util::{
485 path,
486 rel_path::{RelPath, rel_path},
487 };
488
489 const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
490 first_head: UnmergedStatusCode::Updated,
491 second_head: UnmergedStatusCode::Updated,
492 });
493 const ADDED: GitSummary = GitSummary {
494 index: TrackedSummary::ADDED,
495 count: 1,
496 ..GitSummary::UNCHANGED
497 };
498 const MODIFIED: GitSummary = GitSummary {
499 index: TrackedSummary::MODIFIED,
500 count: 1,
501 ..GitSummary::UNCHANGED
502 };
503
504 #[gpui::test]
505 async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) {
506 init_test(cx);
507 let fs = FakeFs::new(cx.background_executor.clone());
508 fs.insert_tree(
509 path!("/root"),
510 json!({
511 "x": {
512 ".git": {},
513 "x1.txt": "foo",
514 "x2.txt": "bar",
515 "y": {
516 ".git": {},
517 "y1.txt": "baz",
518 "y2.txt": "qux"
519 },
520 "z.txt": "sneaky..."
521 },
522 "z": {
523 ".git": {},
524 "z1.txt": "quux",
525 "z2.txt": "quuux"
526 }
527 }),
528 )
529 .await;
530
531 fs.set_status_for_repo(
532 Path::new(path!("/root/x/.git")),
533 &[
534 ("x2.txt", StatusCode::Modified.index()),
535 ("z.txt", StatusCode::Added.index()),
536 ],
537 );
538 fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
539 fs.set_status_for_repo(
540 Path::new(path!("/root/z/.git")),
541 &[("z2.txt", StatusCode::Added.index())],
542 );
543
544 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
545 cx.executor().run_until_parked();
546
547 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
548 (
549 project.git_store().read(cx).repo_snapshots(cx),
550 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
551 )
552 });
553
554 let traversal = GitTraversal::new(
555 &repo_snapshots,
556 worktree_snapshot.traverse_from_path(true, false, true, RelPath::unix("x").unwrap()),
557 );
558 let entries = traversal
559 .map(|entry| (entry.path.clone(), entry.git_summary))
560 .collect::<Vec<_>>();
561 pretty_assertions::assert_eq!(
562 entries,
563 [
564 (rel_path("x/x1.txt").into(), GitSummary::UNCHANGED),
565 (rel_path("x/x2.txt").into(), MODIFIED),
566 (rel_path("x/y/y1.txt").into(), GitSummary::CONFLICT),
567 (rel_path("x/y/y2.txt").into(), GitSummary::UNCHANGED),
568 (rel_path("x/z.txt").into(), ADDED),
569 (rel_path("z/z1.txt").into(), GitSummary::UNCHANGED),
570 (rel_path("z/z2.txt").into(), ADDED),
571 ]
572 )
573 }
574
575 #[gpui::test]
576 async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) {
577 init_test(cx);
578 let fs = FakeFs::new(cx.background_executor.clone());
579 fs.insert_tree(
580 path!("/root"),
581 json!({
582 "x": {
583 ".git": {},
584 "x1.txt": "foo",
585 "x2.txt": "bar",
586 "y": {
587 ".git": {},
588 "y1.txt": "baz",
589 "y2.txt": "qux"
590 },
591 "z.txt": "sneaky..."
592 },
593 "z": {
594 ".git": {},
595 "z1.txt": "quux",
596 "z2.txt": "quuux"
597 }
598 }),
599 )
600 .await;
601
602 fs.set_status_for_repo(
603 Path::new(path!("/root/x/.git")),
604 &[
605 ("x2.txt", StatusCode::Modified.index()),
606 ("z.txt", StatusCode::Added.index()),
607 ],
608 );
609 fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
610
611 fs.set_status_for_repo(
612 Path::new(path!("/root/z/.git")),
613 &[("z2.txt", StatusCode::Added.index())],
614 );
615
616 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
617 cx.executor().run_until_parked();
618
619 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
620 (
621 project.git_store().read(cx).repo_snapshots(cx),
622 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
623 )
624 });
625
626 // Sanity check the propagation for x/y and z
627 check_git_statuses(
628 &repo_snapshots,
629 &worktree_snapshot,
630 &[
631 ("x/y", GitSummary::CONFLICT),
632 ("x/y/y1.txt", GitSummary::CONFLICT),
633 ("x/y/y2.txt", GitSummary::UNCHANGED),
634 ],
635 );
636 check_git_statuses(
637 &repo_snapshots,
638 &worktree_snapshot,
639 &[
640 ("z", ADDED),
641 ("z/z1.txt", GitSummary::UNCHANGED),
642 ("z/z2.txt", ADDED),
643 ],
644 );
645
646 // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
647 check_git_statuses(
648 &repo_snapshots,
649 &worktree_snapshot,
650 &[
651 ("x", MODIFIED + ADDED),
652 ("x/y", GitSummary::CONFLICT),
653 ("x/y/y1.txt", GitSummary::CONFLICT),
654 ],
655 );
656
657 // Sanity check everything around it
658 check_git_statuses(
659 &repo_snapshots,
660 &worktree_snapshot,
661 &[
662 ("x", MODIFIED + ADDED),
663 ("x/x1.txt", GitSummary::UNCHANGED),
664 ("x/x2.txt", MODIFIED),
665 ("x/y", GitSummary::CONFLICT),
666 ("x/y/y1.txt", GitSummary::CONFLICT),
667 ("x/y/y2.txt", GitSummary::UNCHANGED),
668 ("x/z.txt", ADDED),
669 ],
670 );
671
672 // Test the other fundamental case, transitioning from git repository to non-git repository
673 check_git_statuses(
674 &repo_snapshots,
675 &worktree_snapshot,
676 &[
677 ("", GitSummary::UNCHANGED),
678 ("x", MODIFIED + ADDED),
679 ("x/x1.txt", GitSummary::UNCHANGED),
680 ],
681 );
682
683 // And all together now
684 check_git_statuses(
685 &repo_snapshots,
686 &worktree_snapshot,
687 &[
688 ("", GitSummary::UNCHANGED),
689 ("x", MODIFIED + ADDED),
690 ("x/x1.txt", GitSummary::UNCHANGED),
691 ("x/x2.txt", MODIFIED),
692 ("x/y", GitSummary::CONFLICT),
693 ("x/y/y1.txt", GitSummary::CONFLICT),
694 ("x/y/y2.txt", GitSummary::UNCHANGED),
695 ("x/z.txt", ADDED),
696 ("z", ADDED),
697 ("z/z1.txt", GitSummary::UNCHANGED),
698 ("z/z2.txt", ADDED),
699 ],
700 );
701 }
702
703 #[gpui::test]
704 async fn test_git_traversal_simple(cx: &mut TestAppContext) {
705 init_test(cx);
706 let fs = FakeFs::new(cx.background_executor.clone());
707 fs.insert_tree(
708 path!("/root"),
709 json!({
710 ".git": {},
711 "a": {
712 "b": {
713 "c1.txt": "",
714 "c2.txt": "",
715 },
716 "d": {
717 "e1.txt": "",
718 "e2.txt": "",
719 "e3.txt": "",
720 }
721 },
722 "f": {
723 "no-status.txt": ""
724 },
725 "g": {
726 "h1.txt": "",
727 "h2.txt": ""
728 },
729 }),
730 )
731 .await;
732
733 fs.set_status_for_repo(
734 Path::new(path!("/root/.git")),
735 &[
736 ("a/b/c1.txt", StatusCode::Added.index()),
737 ("a/d/e2.txt", StatusCode::Modified.index()),
738 ("g/h2.txt", CONFLICT),
739 ],
740 );
741
742 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
743 cx.executor().run_until_parked();
744
745 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
746 (
747 project.git_store().read(cx).repo_snapshots(cx),
748 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
749 )
750 });
751
752 check_git_statuses(
753 &repo_snapshots,
754 &worktree_snapshot,
755 &[
756 ("", GitSummary::CONFLICT + MODIFIED + ADDED),
757 ("g", GitSummary::CONFLICT),
758 ("g/h2.txt", GitSummary::CONFLICT),
759 ],
760 );
761
762 check_git_statuses(
763 &repo_snapshots,
764 &worktree_snapshot,
765 &[
766 ("", GitSummary::CONFLICT + ADDED + MODIFIED),
767 ("a", ADDED + MODIFIED),
768 ("a/b", ADDED),
769 ("a/b/c1.txt", ADDED),
770 ("a/b/c2.txt", GitSummary::UNCHANGED),
771 ("a/d", MODIFIED),
772 ("a/d/e2.txt", MODIFIED),
773 ("f", GitSummary::UNCHANGED),
774 ("f/no-status.txt", GitSummary::UNCHANGED),
775 ("g", GitSummary::CONFLICT),
776 ("g/h2.txt", GitSummary::CONFLICT),
777 ],
778 );
779
780 check_git_statuses(
781 &repo_snapshots,
782 &worktree_snapshot,
783 &[
784 ("a/b", ADDED),
785 ("a/b/c1.txt", ADDED),
786 ("a/b/c2.txt", GitSummary::UNCHANGED),
787 ("a/d", MODIFIED),
788 ("a/d/e1.txt", GitSummary::UNCHANGED),
789 ("a/d/e2.txt", MODIFIED),
790 ("f", GitSummary::UNCHANGED),
791 ("f/no-status.txt", GitSummary::UNCHANGED),
792 ("g", GitSummary::CONFLICT),
793 ],
794 );
795
796 check_git_statuses(
797 &repo_snapshots,
798 &worktree_snapshot,
799 &[
800 ("a/b/c1.txt", ADDED),
801 ("a/b/c2.txt", GitSummary::UNCHANGED),
802 ("a/d/e1.txt", GitSummary::UNCHANGED),
803 ("a/d/e2.txt", MODIFIED),
804 ("f/no-status.txt", GitSummary::UNCHANGED),
805 ],
806 );
807 }
808
809 #[gpui::test]
810 async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) {
811 init_test(cx);
812 let fs = FakeFs::new(cx.background_executor.clone());
813 fs.insert_tree(
814 path!("/root"),
815 json!({
816 "x": {
817 ".git": {},
818 "x1.txt": "foo",
819 "x2.txt": "bar"
820 },
821 "y": {
822 ".git": {},
823 "y1.txt": "baz",
824 "y2.txt": "qux"
825 },
826 "z": {
827 ".git": {},
828 "z1.txt": "quux",
829 "z2.txt": "quuux"
830 }
831 }),
832 )
833 .await;
834
835 fs.set_status_for_repo(
836 Path::new(path!("/root/x/.git")),
837 &[("x1.txt", StatusCode::Added.index())],
838 );
839 fs.set_status_for_repo(
840 Path::new(path!("/root/y/.git")),
841 &[
842 ("y1.txt", CONFLICT),
843 ("y2.txt", StatusCode::Modified.index()),
844 ],
845 );
846 fs.set_status_for_repo(
847 Path::new(path!("/root/z/.git")),
848 &[("z2.txt", StatusCode::Modified.index())],
849 );
850
851 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
852 cx.executor().run_until_parked();
853
854 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
855 (
856 project.git_store().read(cx).repo_snapshots(cx),
857 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
858 )
859 });
860
861 check_git_statuses(
862 &repo_snapshots,
863 &worktree_snapshot,
864 &[("x", ADDED), ("x/x1.txt", ADDED)],
865 );
866
867 check_git_statuses(
868 &repo_snapshots,
869 &worktree_snapshot,
870 &[
871 ("y", GitSummary::CONFLICT + MODIFIED),
872 ("y/y1.txt", GitSummary::CONFLICT),
873 ("y/y2.txt", MODIFIED),
874 ],
875 );
876
877 check_git_statuses(
878 &repo_snapshots,
879 &worktree_snapshot,
880 &[("z", MODIFIED), ("z/z2.txt", MODIFIED)],
881 );
882
883 check_git_statuses(
884 &repo_snapshots,
885 &worktree_snapshot,
886 &[("x", ADDED), ("x/x1.txt", ADDED)],
887 );
888
889 check_git_statuses(
890 &repo_snapshots,
891 &worktree_snapshot,
892 &[
893 ("x", ADDED),
894 ("x/x1.txt", ADDED),
895 ("x/x2.txt", GitSummary::UNCHANGED),
896 ("y", GitSummary::CONFLICT + MODIFIED),
897 ("y/y1.txt", GitSummary::CONFLICT),
898 ("y/y2.txt", MODIFIED),
899 ("z", MODIFIED),
900 ("z/z1.txt", GitSummary::UNCHANGED),
901 ("z/z2.txt", MODIFIED),
902 ],
903 );
904 }
905
906 fn init_test(cx: &mut gpui::TestAppContext) {
907 zlog::init_test();
908
909 cx.update(|cx| {
910 let settings_store = SettingsStore::test(cx);
911 cx.set_global(settings_store);
912 });
913 }
914
915 #[gpui::test]
916 async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
917 init_test(cx);
918
919 // Create a worktree with a git directory.
920 let fs = FakeFs::new(cx.background_executor.clone());
921 fs.insert_tree(
922 path!("/root"),
923 json!({
924 ".git": {},
925 "a.txt": "",
926 "b": {
927 "c.txt": "",
928 },
929 }),
930 )
931 .await;
932 fs.set_head_and_index_for_repo(
933 path!("/root/.git").as_ref(),
934 &[("a.txt", "".into()), ("b/c.txt", "".into())],
935 );
936 cx.run_until_parked();
937
938 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
939 cx.executor().run_until_parked();
940
941 let (old_entry_ids, old_mtimes) = project.read_with(cx, |project, cx| {
942 let tree = project.worktrees(cx).next().unwrap().read(cx);
943 (
944 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
945 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
946 )
947 });
948
949 // Regression test: after the directory is scanned, touch the git repo's
950 // working directory, bumping its mtime. That directory keeps its project
951 // entry id after the directories are re-scanned.
952 fs.touch_path(path!("/root")).await;
953 cx.executor().run_until_parked();
954
955 let (new_entry_ids, new_mtimes) = project.read_with(cx, |project, cx| {
956 let tree = project.worktrees(cx).next().unwrap().read(cx);
957 (
958 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
959 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
960 )
961 });
962 assert_eq!(new_entry_ids, old_entry_ids);
963 assert_ne!(new_mtimes, old_mtimes);
964
965 // Regression test: changes to the git repository should still be
966 // detected.
967 fs.set_head_for_repo(
968 path!("/root/.git").as_ref(),
969 &[("a.txt", "".into()), ("b/c.txt", "something-else".into())],
970 "deadbeef",
971 );
972 cx.executor().run_until_parked();
973 cx.executor().advance_clock(Duration::from_secs(1));
974
975 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
976 (
977 project.git_store().read(cx).repo_snapshots(cx),
978 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
979 )
980 });
981
982 check_git_statuses(
983 &repo_snapshots,
984 &worktree_snapshot,
985 &[
986 ("", MODIFIED),
987 ("a.txt", GitSummary::UNCHANGED),
988 ("b/c.txt", MODIFIED),
989 ],
990 );
991 }
992
993 #[track_caller]
994 fn check_git_statuses(
995 repo_snapshots: &HashMap<RepositoryId, RepositorySnapshot>,
996 worktree_snapshot: &worktree::Snapshot,
997 expected_statuses: &[(&str, GitSummary)],
998 ) {
999 let mut traversal = GitTraversal::new(
1000 repo_snapshots,
1001 worktree_snapshot.traverse_from_path(true, true, false, RelPath::empty()),
1002 );
1003 let found_statuses = expected_statuses
1004 .iter()
1005 .map(|&(path, _)| {
1006 let git_entry = traversal
1007 .find(|git_entry| git_entry.path.as_ref() == rel_path(path))
1008 .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
1009 (path, git_entry.git_summary)
1010 })
1011 .collect::<Vec<_>>();
1012 pretty_assertions::assert_eq!(found_statuses, expected_statuses);
1013 }
1014}