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 update cached conflicts
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 #[gpui::test]
466 async fn test_conflict_updates_with_delayed_merge_head_conflicts(
467 executor: BackgroundExecutor,
468 cx: &mut TestAppContext,
469 ) {
470 zlog::init_test();
471 cx.update(|cx| {
472 settings::init(cx);
473 });
474
475 let initial_text = "
476 one
477 two
478 three
479 four
480 "
481 .unindent();
482
483 let conflicted_text = "
484 one
485 <<<<<<< HEAD
486 two
487 =======
488 TWO
489 >>>>>>> branch
490 three
491 four
492 "
493 .unindent();
494
495 let resolved_text = "
496 one
497 TWO
498 three
499 four
500 "
501 .unindent();
502
503 let fs = FakeFs::new(executor);
504 fs.insert_tree(
505 path!("/project"),
506 json!({
507 ".git": {},
508 "a.txt": initial_text,
509 }),
510 )
511 .await;
512
513 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
514 let (git_store, buffer) = project.update(cx, |project, cx| {
515 (
516 project.git_store().clone(),
517 project.open_local_buffer(path!("/project/a.txt"), cx),
518 )
519 });
520 let buffer = buffer.await.unwrap();
521 let conflict_set = git_store.update(cx, |git_store, cx| {
522 git_store.open_conflict_set(buffer.clone(), cx)
523 });
524
525 let (events_tx, events_rx) = mpsc::channel::<ConflictSetUpdate>();
526 let _conflict_set_subscription = cx.update(|cx| {
527 cx.subscribe(&conflict_set, move |_, event, _| {
528 events_tx.send(event.clone()).ok();
529 })
530 });
531
532 cx.run_until_parked();
533 events_rx
534 .try_recv()
535 .expect_err("conflict set should start empty");
536
537 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
538 state.refs.insert("MERGE_HEAD".into(), "123".into())
539 })
540 .unwrap();
541
542 cx.run_until_parked();
543 events_rx
544 .try_recv()
545 .expect_err("merge head without conflicted paths should not publish conflicts");
546 conflict_set.update(cx, |conflict_set, _| {
547 assert!(!conflict_set.has_conflict);
548 assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
549 });
550
551 buffer.update(cx, |buffer, cx| {
552 buffer.set_text(conflicted_text.clone(), cx);
553 });
554 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
555 state.unmerged_paths.insert(
556 repo_path("a.txt"),
557 UnmergedStatus {
558 first_head: UnmergedStatusCode::Updated,
559 second_head: UnmergedStatusCode::Updated,
560 },
561 );
562 })
563 .unwrap();
564
565 cx.run_until_parked();
566 let update = events_rx
567 .try_recv()
568 .expect("conflicts should appear once conflicted paths are visible");
569 assert_eq!(update.old_range, 0..0);
570 assert_eq!(update.new_range, 0..1);
571 conflict_set.update(cx, |conflict_set, cx| {
572 assert!(conflict_set.has_conflict);
573 let conflict_range = conflict_set.snapshot().conflicts[0]
574 .range
575 .to_point(buffer.read(cx));
576 assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
577 });
578
579 buffer.update(cx, |buffer, cx| {
580 buffer.set_text(resolved_text.clone(), cx);
581 });
582
583 cx.run_until_parked();
584 let update = events_rx
585 .try_recv()
586 .expect("resolved buffer text should clear visible conflict markers");
587 assert_eq!(update.old_range, 0..1);
588 assert_eq!(update.new_range, 0..0);
589 conflict_set.update(cx, |conflict_set, _| {
590 assert!(conflict_set.has_conflict);
591 assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
592 });
593
594 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
595 state.refs.insert("MERGE_HEAD".into(), "456".into());
596 })
597 .unwrap();
598
599 cx.run_until_parked();
600 events_rx.try_recv().expect_err(
601 "merge-head change without unmerged-path changes should not emit marker updates",
602 );
603 conflict_set.update(cx, |conflict_set, _| {
604 assert!(conflict_set.has_conflict);
605 assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
606 });
607
608 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
609 state.unmerged_paths.remove(&repo_path("a.txt"));
610 state.refs.remove("MERGE_HEAD");
611 })
612 .unwrap();
613
614 cx.run_until_parked();
615 let update = events_rx.try_recv().expect(
616 "status catch-up should emit a no-op update when clearing stale conflict state",
617 );
618 assert_eq!(update.old_range, 0..0);
619 assert_eq!(update.new_range, 0..0);
620 assert!(update.buffer_range.is_none());
621 conflict_set.update(cx, |conflict_set, _| {
622 assert!(!conflict_set.has_conflict);
623 assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
624 });
625 }
626}
627
628mod git_traversal {
629 use std::{path::Path, time::Duration};
630
631 use collections::HashMap;
632 use project::{
633 Project,
634 git_store::{RepositoryId, RepositorySnapshot},
635 };
636
637 use fs::FakeFs;
638 use git::status::{
639 FileStatus, GitSummary, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode,
640 };
641 use gpui::TestAppContext;
642 use project::GitTraversal;
643
644 use serde_json::json;
645 use settings::SettingsStore;
646 use util::{
647 path,
648 rel_path::{RelPath, rel_path},
649 };
650
651 const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
652 first_head: UnmergedStatusCode::Updated,
653 second_head: UnmergedStatusCode::Updated,
654 });
655 const ADDED: GitSummary = GitSummary {
656 index: TrackedSummary::ADDED,
657 count: 1,
658 ..GitSummary::UNCHANGED
659 };
660 const MODIFIED: GitSummary = GitSummary {
661 index: TrackedSummary::MODIFIED,
662 count: 1,
663 ..GitSummary::UNCHANGED
664 };
665
666 #[gpui::test]
667 async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) {
668 init_test(cx);
669 let fs = FakeFs::new(cx.background_executor.clone());
670 fs.insert_tree(
671 path!("/root"),
672 json!({
673 "x": {
674 ".git": {},
675 "x1.txt": "foo",
676 "x2.txt": "bar",
677 "y": {
678 ".git": {},
679 "y1.txt": "baz",
680 "y2.txt": "qux"
681 },
682 "z.txt": "sneaky..."
683 },
684 "z": {
685 ".git": {},
686 "z1.txt": "quux",
687 "z2.txt": "quuux"
688 }
689 }),
690 )
691 .await;
692
693 fs.set_status_for_repo(
694 Path::new(path!("/root/x/.git")),
695 &[
696 ("x2.txt", StatusCode::Modified.index()),
697 ("z.txt", StatusCode::Added.index()),
698 ],
699 );
700 fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
701 fs.set_status_for_repo(
702 Path::new(path!("/root/z/.git")),
703 &[("z2.txt", StatusCode::Added.index())],
704 );
705
706 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
707 cx.executor().run_until_parked();
708
709 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
710 (
711 project.git_store().read(cx).repo_snapshots(cx),
712 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
713 )
714 });
715
716 let traversal = GitTraversal::new(
717 &repo_snapshots,
718 worktree_snapshot.traverse_from_path(true, false, true, RelPath::unix("x").unwrap()),
719 );
720 let entries = traversal
721 .map(|entry| (entry.path.clone(), entry.git_summary))
722 .collect::<Vec<_>>();
723 pretty_assertions::assert_eq!(
724 entries,
725 [
726 (rel_path("x/x1.txt").into(), GitSummary::UNCHANGED),
727 (rel_path("x/x2.txt").into(), MODIFIED),
728 (rel_path("x/y/y1.txt").into(), GitSummary::CONFLICT),
729 (rel_path("x/y/y2.txt").into(), GitSummary::UNCHANGED),
730 (rel_path("x/z.txt").into(), ADDED),
731 (rel_path("z/z1.txt").into(), GitSummary::UNCHANGED),
732 (rel_path("z/z2.txt").into(), ADDED),
733 ]
734 )
735 }
736
737 #[gpui::test]
738 async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) {
739 init_test(cx);
740 let fs = FakeFs::new(cx.background_executor.clone());
741 fs.insert_tree(
742 path!("/root"),
743 json!({
744 "x": {
745 ".git": {},
746 "x1.txt": "foo",
747 "x2.txt": "bar",
748 "y": {
749 ".git": {},
750 "y1.txt": "baz",
751 "y2.txt": "qux"
752 },
753 "z.txt": "sneaky..."
754 },
755 "z": {
756 ".git": {},
757 "z1.txt": "quux",
758 "z2.txt": "quuux"
759 }
760 }),
761 )
762 .await;
763
764 fs.set_status_for_repo(
765 Path::new(path!("/root/x/.git")),
766 &[
767 ("x2.txt", StatusCode::Modified.index()),
768 ("z.txt", StatusCode::Added.index()),
769 ],
770 );
771 fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
772
773 fs.set_status_for_repo(
774 Path::new(path!("/root/z/.git")),
775 &[("z2.txt", StatusCode::Added.index())],
776 );
777
778 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
779 cx.executor().run_until_parked();
780
781 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
782 (
783 project.git_store().read(cx).repo_snapshots(cx),
784 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
785 )
786 });
787
788 // Sanity check the propagation for x/y and z
789 check_git_statuses(
790 &repo_snapshots,
791 &worktree_snapshot,
792 &[
793 ("x/y", GitSummary::CONFLICT),
794 ("x/y/y1.txt", GitSummary::CONFLICT),
795 ("x/y/y2.txt", GitSummary::UNCHANGED),
796 ],
797 );
798 check_git_statuses(
799 &repo_snapshots,
800 &worktree_snapshot,
801 &[
802 ("z", ADDED),
803 ("z/z1.txt", GitSummary::UNCHANGED),
804 ("z/z2.txt", ADDED),
805 ],
806 );
807
808 // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
809 check_git_statuses(
810 &repo_snapshots,
811 &worktree_snapshot,
812 &[
813 ("x", MODIFIED + ADDED),
814 ("x/y", GitSummary::CONFLICT),
815 ("x/y/y1.txt", GitSummary::CONFLICT),
816 ],
817 );
818
819 // Sanity check everything around it
820 check_git_statuses(
821 &repo_snapshots,
822 &worktree_snapshot,
823 &[
824 ("x", MODIFIED + ADDED),
825 ("x/x1.txt", GitSummary::UNCHANGED),
826 ("x/x2.txt", MODIFIED),
827 ("x/y", GitSummary::CONFLICT),
828 ("x/y/y1.txt", GitSummary::CONFLICT),
829 ("x/y/y2.txt", GitSummary::UNCHANGED),
830 ("x/z.txt", ADDED),
831 ],
832 );
833
834 // Test the other fundamental case, transitioning from git repository to non-git repository
835 check_git_statuses(
836 &repo_snapshots,
837 &worktree_snapshot,
838 &[
839 ("", GitSummary::UNCHANGED),
840 ("x", MODIFIED + ADDED),
841 ("x/x1.txt", GitSummary::UNCHANGED),
842 ],
843 );
844
845 // And all together now
846 check_git_statuses(
847 &repo_snapshots,
848 &worktree_snapshot,
849 &[
850 ("", GitSummary::UNCHANGED),
851 ("x", MODIFIED + ADDED),
852 ("x/x1.txt", GitSummary::UNCHANGED),
853 ("x/x2.txt", MODIFIED),
854 ("x/y", GitSummary::CONFLICT),
855 ("x/y/y1.txt", GitSummary::CONFLICT),
856 ("x/y/y2.txt", GitSummary::UNCHANGED),
857 ("x/z.txt", ADDED),
858 ("z", ADDED),
859 ("z/z1.txt", GitSummary::UNCHANGED),
860 ("z/z2.txt", ADDED),
861 ],
862 );
863 }
864
865 #[gpui::test]
866 async fn test_git_traversal_simple(cx: &mut TestAppContext) {
867 init_test(cx);
868 let fs = FakeFs::new(cx.background_executor.clone());
869 fs.insert_tree(
870 path!("/root"),
871 json!({
872 ".git": {},
873 "a": {
874 "b": {
875 "c1.txt": "",
876 "c2.txt": "",
877 },
878 "d": {
879 "e1.txt": "",
880 "e2.txt": "",
881 "e3.txt": "",
882 }
883 },
884 "f": {
885 "no-status.txt": ""
886 },
887 "g": {
888 "h1.txt": "",
889 "h2.txt": ""
890 },
891 }),
892 )
893 .await;
894
895 fs.set_status_for_repo(
896 Path::new(path!("/root/.git")),
897 &[
898 ("a/b/c1.txt", StatusCode::Added.index()),
899 ("a/d/e2.txt", StatusCode::Modified.index()),
900 ("g/h2.txt", CONFLICT),
901 ],
902 );
903
904 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
905 cx.executor().run_until_parked();
906
907 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
908 (
909 project.git_store().read(cx).repo_snapshots(cx),
910 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
911 )
912 });
913
914 check_git_statuses(
915 &repo_snapshots,
916 &worktree_snapshot,
917 &[
918 ("", GitSummary::CONFLICT + MODIFIED + ADDED),
919 ("g", GitSummary::CONFLICT),
920 ("g/h2.txt", GitSummary::CONFLICT),
921 ],
922 );
923
924 check_git_statuses(
925 &repo_snapshots,
926 &worktree_snapshot,
927 &[
928 ("", GitSummary::CONFLICT + ADDED + MODIFIED),
929 ("a", ADDED + MODIFIED),
930 ("a/b", ADDED),
931 ("a/b/c1.txt", ADDED),
932 ("a/b/c2.txt", GitSummary::UNCHANGED),
933 ("a/d", MODIFIED),
934 ("a/d/e2.txt", MODIFIED),
935 ("f", GitSummary::UNCHANGED),
936 ("f/no-status.txt", GitSummary::UNCHANGED),
937 ("g", GitSummary::CONFLICT),
938 ("g/h2.txt", GitSummary::CONFLICT),
939 ],
940 );
941
942 check_git_statuses(
943 &repo_snapshots,
944 &worktree_snapshot,
945 &[
946 ("a/b", ADDED),
947 ("a/b/c1.txt", ADDED),
948 ("a/b/c2.txt", GitSummary::UNCHANGED),
949 ("a/d", MODIFIED),
950 ("a/d/e1.txt", GitSummary::UNCHANGED),
951 ("a/d/e2.txt", MODIFIED),
952 ("f", GitSummary::UNCHANGED),
953 ("f/no-status.txt", GitSummary::UNCHANGED),
954 ("g", GitSummary::CONFLICT),
955 ],
956 );
957
958 check_git_statuses(
959 &repo_snapshots,
960 &worktree_snapshot,
961 &[
962 ("a/b/c1.txt", ADDED),
963 ("a/b/c2.txt", GitSummary::UNCHANGED),
964 ("a/d/e1.txt", GitSummary::UNCHANGED),
965 ("a/d/e2.txt", MODIFIED),
966 ("f/no-status.txt", GitSummary::UNCHANGED),
967 ],
968 );
969 }
970
971 #[gpui::test]
972 async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) {
973 init_test(cx);
974 let fs = FakeFs::new(cx.background_executor.clone());
975 fs.insert_tree(
976 path!("/root"),
977 json!({
978 "x": {
979 ".git": {},
980 "x1.txt": "foo",
981 "x2.txt": "bar"
982 },
983 "y": {
984 ".git": {},
985 "y1.txt": "baz",
986 "y2.txt": "qux"
987 },
988 "z": {
989 ".git": {},
990 "z1.txt": "quux",
991 "z2.txt": "quuux"
992 }
993 }),
994 )
995 .await;
996
997 fs.set_status_for_repo(
998 Path::new(path!("/root/x/.git")),
999 &[("x1.txt", StatusCode::Added.index())],
1000 );
1001 fs.set_status_for_repo(
1002 Path::new(path!("/root/y/.git")),
1003 &[
1004 ("y1.txt", CONFLICT),
1005 ("y2.txt", StatusCode::Modified.index()),
1006 ],
1007 );
1008 fs.set_status_for_repo(
1009 Path::new(path!("/root/z/.git")),
1010 &[("z2.txt", StatusCode::Modified.index())],
1011 );
1012
1013 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
1014 cx.executor().run_until_parked();
1015
1016 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
1017 (
1018 project.git_store().read(cx).repo_snapshots(cx),
1019 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
1020 )
1021 });
1022
1023 check_git_statuses(
1024 &repo_snapshots,
1025 &worktree_snapshot,
1026 &[("x", ADDED), ("x/x1.txt", ADDED)],
1027 );
1028
1029 check_git_statuses(
1030 &repo_snapshots,
1031 &worktree_snapshot,
1032 &[
1033 ("y", GitSummary::CONFLICT + MODIFIED),
1034 ("y/y1.txt", GitSummary::CONFLICT),
1035 ("y/y2.txt", MODIFIED),
1036 ],
1037 );
1038
1039 check_git_statuses(
1040 &repo_snapshots,
1041 &worktree_snapshot,
1042 &[("z", MODIFIED), ("z/z2.txt", MODIFIED)],
1043 );
1044
1045 check_git_statuses(
1046 &repo_snapshots,
1047 &worktree_snapshot,
1048 &[("x", ADDED), ("x/x1.txt", ADDED)],
1049 );
1050
1051 check_git_statuses(
1052 &repo_snapshots,
1053 &worktree_snapshot,
1054 &[
1055 ("x", ADDED),
1056 ("x/x1.txt", ADDED),
1057 ("x/x2.txt", GitSummary::UNCHANGED),
1058 ("y", GitSummary::CONFLICT + MODIFIED),
1059 ("y/y1.txt", GitSummary::CONFLICT),
1060 ("y/y2.txt", MODIFIED),
1061 ("z", MODIFIED),
1062 ("z/z1.txt", GitSummary::UNCHANGED),
1063 ("z/z2.txt", MODIFIED),
1064 ],
1065 );
1066 }
1067
1068 fn init_test(cx: &mut gpui::TestAppContext) {
1069 zlog::init_test();
1070
1071 cx.update(|cx| {
1072 let settings_store = SettingsStore::test(cx);
1073 cx.set_global(settings_store);
1074 });
1075 }
1076
1077 #[gpui::test]
1078 async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
1079 init_test(cx);
1080
1081 // Create a worktree with a git directory.
1082 let fs = FakeFs::new(cx.background_executor.clone());
1083 fs.insert_tree(
1084 path!("/root"),
1085 json!({
1086 ".git": {},
1087 "a.txt": "",
1088 "b": {
1089 "c.txt": "",
1090 },
1091 }),
1092 )
1093 .await;
1094 fs.set_head_and_index_for_repo(
1095 path!("/root/.git").as_ref(),
1096 &[("a.txt", "".into()), ("b/c.txt", "".into())],
1097 );
1098 cx.run_until_parked();
1099
1100 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1101 cx.executor().run_until_parked();
1102
1103 let (old_entry_ids, old_mtimes) = project.read_with(cx, |project, cx| {
1104 let tree = project.worktrees(cx).next().unwrap().read(cx);
1105 (
1106 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
1107 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
1108 )
1109 });
1110
1111 // Regression test: after the directory is scanned, touch the git repo's
1112 // working directory, bumping its mtime. That directory keeps its project
1113 // entry id after the directories are re-scanned.
1114 fs.touch_path(path!("/root")).await;
1115 cx.executor().run_until_parked();
1116
1117 let (new_entry_ids, new_mtimes) = project.read_with(cx, |project, cx| {
1118 let tree = project.worktrees(cx).next().unwrap().read(cx);
1119 (
1120 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
1121 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
1122 )
1123 });
1124 assert_eq!(new_entry_ids, old_entry_ids);
1125 assert_ne!(new_mtimes, old_mtimes);
1126
1127 // Regression test: changes to the git repository should still be
1128 // detected.
1129 fs.set_head_for_repo(
1130 path!("/root/.git").as_ref(),
1131 &[("a.txt", "".into()), ("b/c.txt", "something-else".into())],
1132 "deadbeef",
1133 );
1134 cx.executor().run_until_parked();
1135 cx.executor().advance_clock(Duration::from_secs(1));
1136
1137 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
1138 (
1139 project.git_store().read(cx).repo_snapshots(cx),
1140 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
1141 )
1142 });
1143
1144 check_git_statuses(
1145 &repo_snapshots,
1146 &worktree_snapshot,
1147 &[
1148 ("", MODIFIED),
1149 ("a.txt", GitSummary::UNCHANGED),
1150 ("b/c.txt", MODIFIED),
1151 ],
1152 );
1153 }
1154
1155 #[track_caller]
1156 fn check_git_statuses(
1157 repo_snapshots: &HashMap<RepositoryId, RepositorySnapshot>,
1158 worktree_snapshot: &worktree::Snapshot,
1159 expected_statuses: &[(&str, GitSummary)],
1160 ) {
1161 let mut traversal = GitTraversal::new(
1162 repo_snapshots,
1163 worktree_snapshot.traverse_from_path(true, true, false, RelPath::empty()),
1164 );
1165 let found_statuses = expected_statuses
1166 .iter()
1167 .map(|&(path, _)| {
1168 let git_entry = traversal
1169 .find(|git_entry| git_entry.path.as_ref() == rel_path(path))
1170 .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
1171 (path, git_entry.git_summary)
1172 })
1173 .collect::<Vec<_>>();
1174 pretty_assertions::assert_eq!(found_statuses, expected_statuses);
1175 }
1176}