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}
1177
1178mod git_worktrees {
1179 use fs::FakeFs;
1180 use gpui::TestAppContext;
1181 use project::worktrees_directory_for_repo;
1182 use serde_json::json;
1183 use settings::SettingsStore;
1184 use std::path::{Path, PathBuf};
1185 use util::path;
1186 fn init_test(cx: &mut gpui::TestAppContext) {
1187 zlog::init_test();
1188
1189 cx.update(|cx| {
1190 let settings_store = SettingsStore::test(cx);
1191 cx.set_global(settings_store);
1192 });
1193 }
1194
1195 #[test]
1196 fn test_validate_worktree_directory() {
1197 let work_dir = Path::new("/code/my-project");
1198
1199 // Valid: sibling
1200 assert!(worktrees_directory_for_repo(work_dir, "../worktrees").is_ok());
1201
1202 // Valid: subdirectory
1203 assert!(worktrees_directory_for_repo(work_dir, ".git/zed-worktrees").is_ok());
1204 assert!(worktrees_directory_for_repo(work_dir, "my-worktrees").is_ok());
1205
1206 // Invalid: just ".." would resolve back to the working directory itself
1207 let err = worktrees_directory_for_repo(work_dir, "..").unwrap_err();
1208 assert!(err.to_string().contains("must not be \"..\""));
1209
1210 // Invalid: ".." with trailing separators
1211 let err = worktrees_directory_for_repo(work_dir, "..\\").unwrap_err();
1212 assert!(err.to_string().contains("must not be \"..\""));
1213 let err = worktrees_directory_for_repo(work_dir, "../").unwrap_err();
1214 assert!(err.to_string().contains("must not be \"..\""));
1215
1216 // Invalid: empty string would resolve to the working directory itself
1217 let err = worktrees_directory_for_repo(work_dir, "").unwrap_err();
1218 assert!(err.to_string().contains("must not be empty"));
1219
1220 // Invalid: absolute path
1221 let err = worktrees_directory_for_repo(work_dir, "/tmp/worktrees").unwrap_err();
1222 assert!(err.to_string().contains("relative path"));
1223
1224 // Invalid: "/" is absolute on Unix
1225 let err = worktrees_directory_for_repo(work_dir, "/").unwrap_err();
1226 assert!(err.to_string().contains("relative path"));
1227
1228 // Invalid: "///" is absolute
1229 let err = worktrees_directory_for_repo(work_dir, "///").unwrap_err();
1230 assert!(err.to_string().contains("relative path"));
1231
1232 // Invalid: escapes too far up
1233 let err = worktrees_directory_for_repo(work_dir, "../../other-project/wt").unwrap_err();
1234 assert!(err.to_string().contains("outside"));
1235 }
1236
1237 #[gpui::test]
1238 async fn test_git_worktrees_list_and_create(cx: &mut TestAppContext) {
1239 init_test(cx);
1240 let fs = FakeFs::new(cx.background_executor.clone());
1241 fs.insert_tree(
1242 path!("/root"),
1243 json!({
1244 ".git": {},
1245 "file.txt": "content",
1246 }),
1247 )
1248 .await;
1249
1250 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1251 cx.executor().run_until_parked();
1252
1253 let repository = project.read_with(cx, |project, cx| {
1254 project.repositories(cx).values().next().unwrap().clone()
1255 });
1256
1257 let worktrees = cx
1258 .update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
1259 .await
1260 .unwrap()
1261 .unwrap();
1262 assert_eq!(worktrees.len(), 1);
1263 assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
1264
1265 let worktrees_directory = PathBuf::from(path!("/root"));
1266 let worktree_1_directory = worktrees_directory.join("feature-branch");
1267 cx.update(|cx| {
1268 repository.update(cx, |repository, _| {
1269 repository.create_worktree(
1270 "feature-branch".to_string(),
1271 worktree_1_directory.clone(),
1272 Some("abc123".to_string()),
1273 )
1274 })
1275 })
1276 .await
1277 .unwrap()
1278 .unwrap();
1279
1280 cx.executor().run_until_parked();
1281
1282 let worktrees = cx
1283 .update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
1284 .await
1285 .unwrap()
1286 .unwrap();
1287 assert_eq!(worktrees.len(), 2);
1288 assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
1289 assert_eq!(worktrees[1].path, worktree_1_directory);
1290 assert_eq!(
1291 worktrees[1].ref_name,
1292 Some("refs/heads/feature-branch".into())
1293 );
1294 assert_eq!(worktrees[1].sha.as_ref(), "abc123");
1295
1296 let worktree_2_directory = worktrees_directory.join("bugfix-branch");
1297 cx.update(|cx| {
1298 repository.update(cx, |repository, _| {
1299 repository.create_worktree(
1300 "bugfix-branch".to_string(),
1301 worktree_2_directory.clone(),
1302 None,
1303 )
1304 })
1305 })
1306 .await
1307 .unwrap()
1308 .unwrap();
1309
1310 cx.executor().run_until_parked();
1311
1312 // List worktrees — should now have main + two created
1313 let worktrees = cx
1314 .update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
1315 .await
1316 .unwrap()
1317 .unwrap();
1318 assert_eq!(worktrees.len(), 3);
1319
1320 let worktree_1 = worktrees
1321 .iter()
1322 .find(|worktree| worktree.ref_name == Some("refs/heads/feature-branch".into()))
1323 .expect("should find feature-branch worktree");
1324 assert_eq!(worktree_1.path, worktree_1_directory);
1325
1326 let worktree_2 = worktrees
1327 .iter()
1328 .find(|worktree| worktree.ref_name == Some("refs/heads/bugfix-branch".into()))
1329 .expect("should find bugfix-branch worktree");
1330 assert_eq!(worktree_2.path, worktree_2_directory);
1331 assert_eq!(worktree_2.sha.as_ref(), "fake-sha");
1332 }
1333
1334 use crate::Project;
1335}
1336
1337mod trust_tests {
1338 use collections::HashSet;
1339 use fs::FakeFs;
1340 use gpui::TestAppContext;
1341 use project::trusted_worktrees::*;
1342
1343 use serde_json::json;
1344 use settings::SettingsStore;
1345 use util::path;
1346
1347 use crate::Project;
1348
1349 fn init_test(cx: &mut TestAppContext) {
1350 zlog::init_test();
1351
1352 cx.update(|cx| {
1353 let settings_store = SettingsStore::test(cx);
1354 cx.set_global(settings_store);
1355 });
1356 }
1357
1358 #[gpui::test]
1359 async fn test_repository_defaults_to_untrusted_without_trust_system(cx: &mut TestAppContext) {
1360 init_test(cx);
1361 let fs = FakeFs::new(cx.background_executor.clone());
1362 fs.insert_tree(
1363 path!("/project"),
1364 json!({
1365 ".git": {},
1366 "a.txt": "hello",
1367 }),
1368 )
1369 .await;
1370
1371 // Create project without trust system — repos should default to untrusted.
1372 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
1373 cx.executor().run_until_parked();
1374
1375 let repository = project.read_with(cx, |project, cx| {
1376 project.repositories(cx).values().next().unwrap().clone()
1377 });
1378
1379 repository.read_with(cx, |repo, _| {
1380 assert!(
1381 !repo.is_trusted(),
1382 "repository should default to untrusted when no trust system is initialized"
1383 );
1384 });
1385 }
1386
1387 #[gpui::test]
1388 async fn test_multiple_repos_trust_with_single_worktree(cx: &mut TestAppContext) {
1389 init_test(cx);
1390 let fs = FakeFs::new(cx.background_executor.clone());
1391 fs.insert_tree(
1392 path!("/project"),
1393 json!({
1394 ".git": {},
1395 "a.txt": "hello",
1396 "sub": {
1397 ".git": {},
1398 "b.txt": "world",
1399 },
1400 }),
1401 )
1402 .await;
1403
1404 cx.update(|cx| {
1405 init(DbTrustedPaths::default(), cx);
1406 });
1407
1408 let project =
1409 Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await;
1410 cx.executor().run_until_parked();
1411
1412 let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
1413 let worktree_id = worktree_store.read_with(cx, |store, cx| {
1414 store.worktrees().next().unwrap().read(cx).id()
1415 });
1416
1417 let repos = project.read_with(cx, |project, cx| {
1418 project
1419 .repositories(cx)
1420 .values()
1421 .cloned()
1422 .collect::<Vec<_>>()
1423 });
1424 assert_eq!(repos.len(), 2, "should have two repositories");
1425 for repo in &repos {
1426 repo.read_with(cx, |repo, _| {
1427 assert!(
1428 !repo.is_trusted(),
1429 "all repos should be untrusted initially"
1430 );
1431 });
1432 }
1433
1434 let trusted_worktrees = cx
1435 .update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set"));
1436 trusted_worktrees.update(cx, |store, cx| {
1437 store.trust(
1438 &worktree_store,
1439 HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1440 cx,
1441 );
1442 });
1443 cx.executor().run_until_parked();
1444
1445 for repo in &repos {
1446 repo.read_with(cx, |repo, _| {
1447 assert!(
1448 repo.is_trusted(),
1449 "all repos should be trusted after worktree is trusted"
1450 );
1451 });
1452 }
1453 }
1454
1455 #[gpui::test]
1456 async fn test_repository_trust_restrict_trust_cycle(cx: &mut TestAppContext) {
1457 init_test(cx);
1458 let fs = FakeFs::new(cx.background_executor.clone());
1459 fs.insert_tree(
1460 path!("/project"),
1461 json!({
1462 ".git": {},
1463 "a.txt": "hello",
1464 }),
1465 )
1466 .await;
1467
1468 cx.update(|cx| {
1469 project::trusted_worktrees::init(DbTrustedPaths::default(), cx);
1470 });
1471
1472 let project =
1473 Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await;
1474 cx.executor().run_until_parked();
1475
1476 let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
1477 let worktree_id = worktree_store.read_with(cx, |store, cx| {
1478 store.worktrees().next().unwrap().read(cx).id()
1479 });
1480
1481 let repository = project.read_with(cx, |project, cx| {
1482 project.repositories(cx).values().next().unwrap().clone()
1483 });
1484
1485 repository.read_with(cx, |repo, _| {
1486 assert!(!repo.is_trusted(), "repository should start untrusted");
1487 });
1488
1489 let trusted_worktrees = cx
1490 .update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set"));
1491
1492 trusted_worktrees.update(cx, |store, cx| {
1493 store.trust(
1494 &worktree_store,
1495 HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1496 cx,
1497 );
1498 });
1499 cx.executor().run_until_parked();
1500
1501 repository.read_with(cx, |repo, _| {
1502 assert!(
1503 repo.is_trusted(),
1504 "repository should be trusted after worktree is trusted"
1505 );
1506 });
1507
1508 trusted_worktrees.update(cx, |store, cx| {
1509 store.restrict(
1510 worktree_store.downgrade(),
1511 HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1512 cx,
1513 );
1514 });
1515 cx.executor().run_until_parked();
1516
1517 repository.read_with(cx, |repo, _| {
1518 assert!(
1519 !repo.is_trusted(),
1520 "repository should be untrusted after worktree is restricted"
1521 );
1522 });
1523
1524 trusted_worktrees.update(cx, |store, cx| {
1525 store.trust(
1526 &worktree_store,
1527 HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1528 cx,
1529 );
1530 });
1531 cx.executor().run_until_parked();
1532
1533 repository.read_with(cx, |repo, _| {
1534 assert!(
1535 repo.is_trusted(),
1536 "repository should be trusted again after second trust"
1537 );
1538 });
1539 }
1540}
1541
1542mod resolve_worktree_tests {
1543 use fs::FakeFs;
1544 use gpui::TestAppContext;
1545 use project::{git_store::resolve_git_worktree_to_main_repo, linked_worktree_short_name};
1546 use serde_json::json;
1547 use std::path::{Path, PathBuf};
1548
1549 #[gpui::test]
1550 async fn test_resolve_git_worktree_to_main_repo(cx: &mut TestAppContext) {
1551 let fs = FakeFs::new(cx.executor());
1552 // Set up a main repo with a worktree entry
1553 fs.insert_tree(
1554 "/main-repo",
1555 json!({
1556 ".git": {
1557 "worktrees": {
1558 "feature": {
1559 "commondir": "../../",
1560 "HEAD": "ref: refs/heads/feature"
1561 }
1562 }
1563 },
1564 "src": { "main.rs": "" }
1565 }),
1566 )
1567 .await;
1568 // Set up a worktree checkout pointing back to the main repo
1569 fs.insert_tree(
1570 "/worktree-checkout",
1571 json!({
1572 ".git": "gitdir: /main-repo/.git/worktrees/feature",
1573 "src": { "main.rs": "" }
1574 }),
1575 )
1576 .await;
1577
1578 let result =
1579 resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/worktree-checkout")).await;
1580 assert_eq!(result, Some(PathBuf::from("/main-repo")));
1581 }
1582
1583 #[gpui::test]
1584 async fn test_resolve_git_worktree_normal_repo_returns_none(cx: &mut TestAppContext) {
1585 let fs = FakeFs::new(cx.executor());
1586 fs.insert_tree(
1587 "/repo",
1588 json!({
1589 ".git": {},
1590 "src": { "main.rs": "" }
1591 }),
1592 )
1593 .await;
1594
1595 let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/repo")).await;
1596 assert_eq!(result, None);
1597 }
1598
1599 #[gpui::test]
1600 async fn test_resolve_git_worktree_no_git_returns_none(cx: &mut TestAppContext) {
1601 let fs = FakeFs::new(cx.executor());
1602 fs.insert_tree(
1603 "/plain",
1604 json!({
1605 "src": { "main.rs": "" }
1606 }),
1607 )
1608 .await;
1609
1610 let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/plain")).await;
1611 assert_eq!(result, None);
1612 }
1613
1614 #[gpui::test]
1615 async fn test_resolve_git_worktree_nonexistent_returns_none(cx: &mut TestAppContext) {
1616 let fs = FakeFs::new(cx.executor());
1617
1618 let result =
1619 resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/does-not-exist")).await;
1620 assert_eq!(result, None);
1621 }
1622
1623 #[test]
1624 fn test_linked_worktree_short_name() {
1625 let examples = [
1626 (
1627 "/home/bob/zed",
1628 "/home/bob/worktrees/olivetti/zed",
1629 Some("olivetti".into()),
1630 ),
1631 ("/home/bob/zed", "/home/bob/zed2", Some("zed2".into())),
1632 (
1633 "/home/bob/zed",
1634 "/home/bob/worktrees/zed/selectric",
1635 Some("selectric".into()),
1636 ),
1637 ("/home/bob/zed", "/home/bob/zed", None),
1638 ];
1639 for (main_worktree_path, linked_worktree_path, expected) in examples {
1640 let short_name = linked_worktree_short_name(
1641 Path::new(main_worktree_path),
1642 Path::new(linked_worktree_path),
1643 );
1644 assert_eq!(
1645 short_name, expected,
1646 "short name for {linked_worktree_path:?}, linked worktree of {main_worktree_path:?}, should be {expected:?}"
1647 );
1648 }
1649 }
1650}