Skip to repository content1799 lines · 60.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T06:49:06.557Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
git_store.rs
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
303 .update(cx, |git_store, cx| {
304 git_store.open_conflict_set(buffer.clone(), cx)
305 })
306 .await;
307 let (events_tx, events_rx) = mpsc::channel::<ConflictSetUpdate>();
308 let _conflict_set_subscription = cx.update(|cx| {
309 cx.subscribe(&conflict_set, move |_, event, _| {
310 events_tx.send(event.clone()).ok();
311 })
312 });
313 let conflicts_snapshot =
314 conflict_set.read_with(cx, |conflict_set, _| conflict_set.snapshot());
315 assert!(conflicts_snapshot.conflicts.is_empty());
316
317 buffer.update(cx, |buffer, cx| {
318 buffer.edit(
319 [
320 (4..4, "<<<<<<< HEAD\n"),
321 (14..14, "=======\nTWO\n>>>>>>> branch\n"),
322 ],
323 None,
324 cx,
325 );
326 });
327
328 cx.run_until_parked();
329 events_rx.try_recv().expect_err(
330 "no conflicts should be registered as long as the file's status is unchanged",
331 );
332
333 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
334 state.unmerged_paths.insert(
335 repo_path("a.txt"),
336 UnmergedStatus {
337 first_head: UnmergedStatusCode::Updated,
338 second_head: UnmergedStatusCode::Updated,
339 },
340 );
341 // Cause the repository to update cached conflicts
342 state.refs.insert("MERGE_HEAD".into(), "123".into())
343 })
344 .unwrap();
345
346 cx.run_until_parked();
347 let update = events_rx
348 .try_recv()
349 .expect("status change should trigger conflict parsing");
350 assert_eq!(update.old_range, 0..0);
351 assert_eq!(update.new_range, 0..1);
352
353 let conflict = conflict_set.read_with(cx, |conflict_set, _| {
354 conflict_set.snapshot().conflicts[0].clone()
355 });
356 cx.update(|cx| {
357 conflict.resolve(buffer.clone(), std::slice::from_ref(&conflict.theirs), cx);
358 });
359
360 cx.run_until_parked();
361 let update = events_rx
362 .try_recv()
363 .expect("conflicts should be removed after resolution");
364 assert_eq!(update.old_range, 0..1);
365 assert_eq!(update.new_range, 0..0);
366 }
367
368 #[gpui::test]
369 async fn test_conflict_updates_without_merge_head(
370 executor: BackgroundExecutor,
371 cx: &mut TestAppContext,
372 ) {
373 zlog::init_test();
374 cx.update(|cx| {
375 settings::init(cx);
376 });
377
378 let initial_text = "
379 zero
380 <<<<<<< HEAD
381 one
382 =======
383 two
384 >>>>>>> Stashed Changes
385 three
386 "
387 .unindent();
388
389 let fs = FakeFs::new(executor);
390 fs.insert_tree(
391 path!("/project"),
392 json!({
393 ".git": {},
394 "a.txt": initial_text,
395 }),
396 )
397 .await;
398
399 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
400 let (git_store, buffer) = project.update(cx, |project, cx| {
401 (
402 project.git_store().clone(),
403 project.open_local_buffer(path!("/project/a.txt"), cx),
404 )
405 });
406
407 cx.run_until_parked();
408 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
409 state.unmerged_paths.insert(
410 RepoPath::from_rel_path(rel_path("a.txt")),
411 UnmergedStatus {
412 first_head: UnmergedStatusCode::Updated,
413 second_head: UnmergedStatusCode::Updated,
414 },
415 )
416 })
417 .unwrap();
418
419 let buffer = buffer.await.unwrap();
420
421 // Open the conflict set for a file that currently has conflicts.
422 let conflict_set = git_store
423 .update(cx, |git_store, cx| {
424 git_store.open_conflict_set(buffer.clone(), cx)
425 })
426 .await;
427
428 cx.run_until_parked();
429 conflict_set.update(cx, |conflict_set, cx| {
430 let conflict_range = conflict_set.snapshot().conflicts[0]
431 .range
432 .to_point(buffer.read(cx));
433 assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
434 });
435
436 // Simulate the conflict being removed by e.g. staging the file.
437 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
438 state.unmerged_paths.remove(&repo_path("a.txt"))
439 })
440 .unwrap();
441
442 cx.run_until_parked();
443 conflict_set.update(cx, |conflict_set, _| {
444 assert!(!conflict_set.has_conflict);
445 assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
446 });
447
448 // Simulate the conflict being re-added.
449 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
450 state.unmerged_paths.insert(
451 repo_path("a.txt"),
452 UnmergedStatus {
453 first_head: UnmergedStatusCode::Updated,
454 second_head: UnmergedStatusCode::Updated,
455 },
456 )
457 })
458 .unwrap();
459
460 cx.run_until_parked();
461 conflict_set.update(cx, |conflict_set, cx| {
462 let conflict_range = conflict_set.snapshot().conflicts[0]
463 .range
464 .to_point(buffer.read(cx));
465 assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
466 });
467 }
468
469 #[gpui::test]
470 async fn test_conflict_updates_with_delayed_merge_head_conflicts(
471 executor: BackgroundExecutor,
472 cx: &mut TestAppContext,
473 ) {
474 zlog::init_test();
475 cx.update(|cx| {
476 settings::init(cx);
477 });
478
479 let initial_text = "
480 one
481 two
482 three
483 four
484 "
485 .unindent();
486
487 let conflicted_text = "
488 one
489 <<<<<<< HEAD
490 two
491 =======
492 TWO
493 >>>>>>> branch
494 three
495 four
496 "
497 .unindent();
498
499 let resolved_text = "
500 one
501 TWO
502 three
503 four
504 "
505 .unindent();
506
507 let fs = FakeFs::new(executor);
508 fs.insert_tree(
509 path!("/project"),
510 json!({
511 ".git": {},
512 "a.txt": initial_text,
513 }),
514 )
515 .await;
516
517 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
518 let (git_store, buffer) = project.update(cx, |project, cx| {
519 (
520 project.git_store().clone(),
521 project.open_local_buffer(path!("/project/a.txt"), cx),
522 )
523 });
524 let buffer = buffer.await.unwrap();
525 let conflict_set = git_store
526 .update(cx, |git_store, cx| {
527 git_store.open_conflict_set(buffer.clone(), cx)
528 })
529 .await;
530
531 let (events_tx, events_rx) = mpsc::channel::<ConflictSetUpdate>();
532 let _conflict_set_subscription = cx.update(|cx| {
533 cx.subscribe(&conflict_set, move |_, event, _| {
534 events_tx.send(event.clone()).ok();
535 })
536 });
537
538 cx.run_until_parked();
539 events_rx
540 .try_recv()
541 .expect_err("conflict set should start empty");
542
543 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
544 state.refs.insert("MERGE_HEAD".into(), "123".into())
545 })
546 .unwrap();
547
548 cx.run_until_parked();
549 events_rx
550 .try_recv()
551 .expect_err("merge head without conflicted paths should not publish conflicts");
552 conflict_set.update(cx, |conflict_set, _| {
553 assert!(!conflict_set.has_conflict);
554 assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
555 });
556
557 buffer.update(cx, |buffer, cx| {
558 buffer.set_text(conflicted_text.clone(), cx);
559 });
560 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
561 state.unmerged_paths.insert(
562 repo_path("a.txt"),
563 UnmergedStatus {
564 first_head: UnmergedStatusCode::Updated,
565 second_head: UnmergedStatusCode::Updated,
566 },
567 );
568 })
569 .unwrap();
570
571 cx.run_until_parked();
572 let update = events_rx
573 .try_recv()
574 .expect("conflicts should appear once conflicted paths are visible");
575 assert_eq!(update.old_range, 0..0);
576 assert_eq!(update.new_range, 0..1);
577 conflict_set.update(cx, |conflict_set, cx| {
578 assert!(conflict_set.has_conflict);
579 let conflict_range = conflict_set.snapshot().conflicts[0]
580 .range
581 .to_point(buffer.read(cx));
582 assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
583 });
584
585 buffer.update(cx, |buffer, cx| {
586 buffer.set_text(resolved_text.clone(), cx);
587 });
588
589 cx.run_until_parked();
590 let update = events_rx
591 .try_recv()
592 .expect("resolved buffer text should clear visible conflict markers");
593 assert_eq!(update.old_range, 0..1);
594 assert_eq!(update.new_range, 0..0);
595 conflict_set.update(cx, |conflict_set, _| {
596 assert!(conflict_set.has_conflict);
597 assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
598 });
599
600 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
601 state.refs.insert("MERGE_HEAD".into(), "456".into());
602 })
603 .unwrap();
604
605 cx.run_until_parked();
606 events_rx.try_recv().expect_err(
607 "merge-head change without unmerged-path changes should not emit marker updates",
608 );
609 conflict_set.update(cx, |conflict_set, _| {
610 assert!(conflict_set.has_conflict);
611 assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
612 });
613
614 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
615 state.unmerged_paths.remove(&repo_path("a.txt"));
616 state.refs.remove("MERGE_HEAD");
617 })
618 .unwrap();
619
620 cx.run_until_parked();
621 let update = events_rx.try_recv().expect(
622 "status catch-up should emit a no-op update when clearing stale conflict state",
623 );
624 assert_eq!(update.old_range, 0..0);
625 assert_eq!(update.new_range, 0..0);
626 assert!(update.buffer_range.is_none());
627 conflict_set.update(cx, |conflict_set, _| {
628 assert!(!conflict_set.has_conflict);
629 assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
630 });
631 }
632}
633
634mod git_traversal {
635 use std::{path::Path, time::Duration};
636
637 use collections::HashMap;
638 use project::{
639 Project,
640 git_store::{RepositoryId, RepositorySnapshot},
641 };
642
643 use fs::FakeFs;
644 use git::status::{
645 FileStatus, GitSummary, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode,
646 };
647 use gpui::TestAppContext;
648 use project::GitTraversal;
649
650 use serde_json::json;
651 use settings::SettingsStore;
652 use util::{
653 path,
654 rel_path::{RelPath, rel_path},
655 };
656
657 const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
658 first_head: UnmergedStatusCode::Updated,
659 second_head: UnmergedStatusCode::Updated,
660 });
661 const ADDED: GitSummary = GitSummary {
662 index: TrackedSummary::ADDED,
663 count: 1,
664 ..GitSummary::UNCHANGED
665 };
666 const MODIFIED: GitSummary = GitSummary {
667 index: TrackedSummary::MODIFIED,
668 count: 1,
669 ..GitSummary::UNCHANGED
670 };
671
672 #[gpui::test]
673 async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) {
674 init_test(cx);
675 let fs = FakeFs::new(cx.background_executor.clone());
676 fs.insert_tree(
677 path!("/root"),
678 json!({
679 "x": {
680 ".git": {},
681 "x1.txt": "foo",
682 "x2.txt": "bar",
683 "y": {
684 ".git": {},
685 "y1.txt": "baz",
686 "y2.txt": "qux"
687 },
688 "z.txt": "sneaky..."
689 },
690 "z": {
691 ".git": {},
692 "z1.txt": "quux",
693 "z2.txt": "quuux"
694 }
695 }),
696 )
697 .await;
698
699 fs.set_status_for_repo(
700 Path::new(path!("/root/x/.git")),
701 &[
702 ("x2.txt", StatusCode::Modified.index()),
703 ("z.txt", StatusCode::Added.index()),
704 ],
705 );
706 fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
707 fs.set_status_for_repo(
708 Path::new(path!("/root/z/.git")),
709 &[("z2.txt", StatusCode::Added.index())],
710 );
711
712 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
713 cx.executor().run_until_parked();
714
715 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
716 (
717 project.git_store().read(cx).repo_snapshots(cx),
718 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
719 )
720 });
721
722 let traversal = GitTraversal::new(
723 &repo_snapshots,
724 worktree_snapshot.traverse_from_path(
725 true,
726 false,
727 true,
728 RelPath::from_unix_str("x").unwrap(),
729 ),
730 );
731 let entries = traversal
732 .map(|entry| (entry.path.clone(), entry.git_summary))
733 .collect::<Vec<_>>();
734 pretty_assertions::assert_eq!(
735 entries,
736 [
737 (rel_path("x/x1.txt").into(), GitSummary::UNCHANGED),
738 (rel_path("x/x2.txt").into(), MODIFIED),
739 (rel_path("x/y/y1.txt").into(), GitSummary::CONFLICT),
740 (rel_path("x/y/y2.txt").into(), GitSummary::UNCHANGED),
741 (rel_path("x/z.txt").into(), ADDED),
742 (rel_path("z/z1.txt").into(), GitSummary::UNCHANGED),
743 (rel_path("z/z2.txt").into(), ADDED),
744 ]
745 )
746 }
747
748 #[gpui::test]
749 async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) {
750 init_test(cx);
751 let fs = FakeFs::new(cx.background_executor.clone());
752 fs.insert_tree(
753 path!("/root"),
754 json!({
755 "x": {
756 ".git": {},
757 "x1.txt": "foo",
758 "x2.txt": "bar",
759 "y": {
760 ".git": {},
761 "y1.txt": "baz",
762 "y2.txt": "qux"
763 },
764 "z.txt": "sneaky..."
765 },
766 "z": {
767 ".git": {},
768 "z1.txt": "quux",
769 "z2.txt": "quuux"
770 }
771 }),
772 )
773 .await;
774
775 fs.set_status_for_repo(
776 Path::new(path!("/root/x/.git")),
777 &[
778 ("x2.txt", StatusCode::Modified.index()),
779 ("z.txt", StatusCode::Added.index()),
780 ],
781 );
782 fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
783
784 fs.set_status_for_repo(
785 Path::new(path!("/root/z/.git")),
786 &[("z2.txt", StatusCode::Added.index())],
787 );
788
789 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
790 cx.executor().run_until_parked();
791
792 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
793 (
794 project.git_store().read(cx).repo_snapshots(cx),
795 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
796 )
797 });
798
799 // Sanity check the propagation for x/y and z
800 check_git_statuses(
801 &repo_snapshots,
802 &worktree_snapshot,
803 &[
804 ("x/y", GitSummary::CONFLICT),
805 ("x/y/y1.txt", GitSummary::CONFLICT),
806 ("x/y/y2.txt", GitSummary::UNCHANGED),
807 ],
808 );
809 check_git_statuses(
810 &repo_snapshots,
811 &worktree_snapshot,
812 &[
813 ("z", ADDED),
814 ("z/z1.txt", GitSummary::UNCHANGED),
815 ("z/z2.txt", ADDED),
816 ],
817 );
818
819 // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
820 check_git_statuses(
821 &repo_snapshots,
822 &worktree_snapshot,
823 &[
824 ("x", MODIFIED + ADDED),
825 ("x/y", GitSummary::CONFLICT),
826 ("x/y/y1.txt", GitSummary::CONFLICT),
827 ],
828 );
829
830 // Sanity check everything around it
831 check_git_statuses(
832 &repo_snapshots,
833 &worktree_snapshot,
834 &[
835 ("x", MODIFIED + ADDED),
836 ("x/x1.txt", GitSummary::UNCHANGED),
837 ("x/x2.txt", MODIFIED),
838 ("x/y", GitSummary::CONFLICT),
839 ("x/y/y1.txt", GitSummary::CONFLICT),
840 ("x/y/y2.txt", GitSummary::UNCHANGED),
841 ("x/z.txt", ADDED),
842 ],
843 );
844
845 // Test the other fundamental case, transitioning from git repository to non-git repository
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 ],
854 );
855
856 // And all together now
857 check_git_statuses(
858 &repo_snapshots,
859 &worktree_snapshot,
860 &[
861 ("", GitSummary::UNCHANGED),
862 ("x", MODIFIED + ADDED),
863 ("x/x1.txt", GitSummary::UNCHANGED),
864 ("x/x2.txt", MODIFIED),
865 ("x/y", GitSummary::CONFLICT),
866 ("x/y/y1.txt", GitSummary::CONFLICT),
867 ("x/y/y2.txt", GitSummary::UNCHANGED),
868 ("x/z.txt", ADDED),
869 ("z", ADDED),
870 ("z/z1.txt", GitSummary::UNCHANGED),
871 ("z/z2.txt", ADDED),
872 ],
873 );
874 }
875
876 #[gpui::test]
877 async fn test_git_traversal_simple(cx: &mut TestAppContext) {
878 init_test(cx);
879 let fs = FakeFs::new(cx.background_executor.clone());
880 fs.insert_tree(
881 path!("/root"),
882 json!({
883 ".git": {},
884 "a": {
885 "b": {
886 "c1.txt": "",
887 "c2.txt": "",
888 },
889 "d": {
890 "e1.txt": "",
891 "e2.txt": "",
892 "e3.txt": "",
893 }
894 },
895 "f": {
896 "no-status.txt": ""
897 },
898 "g": {
899 "h1.txt": "",
900 "h2.txt": ""
901 },
902 }),
903 )
904 .await;
905
906 fs.set_status_for_repo(
907 Path::new(path!("/root/.git")),
908 &[
909 ("a/b/c1.txt", StatusCode::Added.index()),
910 ("a/d/e2.txt", StatusCode::Modified.index()),
911 ("g/h2.txt", CONFLICT),
912 ],
913 );
914
915 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
916 cx.executor().run_until_parked();
917
918 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
919 (
920 project.git_store().read(cx).repo_snapshots(cx),
921 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
922 )
923 });
924
925 check_git_statuses(
926 &repo_snapshots,
927 &worktree_snapshot,
928 &[
929 ("", GitSummary::CONFLICT + MODIFIED + ADDED),
930 ("g", GitSummary::CONFLICT),
931 ("g/h2.txt", GitSummary::CONFLICT),
932 ],
933 );
934
935 check_git_statuses(
936 &repo_snapshots,
937 &worktree_snapshot,
938 &[
939 ("", GitSummary::CONFLICT + ADDED + MODIFIED),
940 ("a", ADDED + MODIFIED),
941 ("a/b", ADDED),
942 ("a/b/c1.txt", ADDED),
943 ("a/b/c2.txt", GitSummary::UNCHANGED),
944 ("a/d", MODIFIED),
945 ("a/d/e2.txt", MODIFIED),
946 ("f", GitSummary::UNCHANGED),
947 ("f/no-status.txt", GitSummary::UNCHANGED),
948 ("g", GitSummary::CONFLICT),
949 ("g/h2.txt", GitSummary::CONFLICT),
950 ],
951 );
952
953 check_git_statuses(
954 &repo_snapshots,
955 &worktree_snapshot,
956 &[
957 ("a/b", ADDED),
958 ("a/b/c1.txt", ADDED),
959 ("a/b/c2.txt", GitSummary::UNCHANGED),
960 ("a/d", MODIFIED),
961 ("a/d/e1.txt", GitSummary::UNCHANGED),
962 ("a/d/e2.txt", MODIFIED),
963 ("f", GitSummary::UNCHANGED),
964 ("f/no-status.txt", GitSummary::UNCHANGED),
965 ("g", GitSummary::CONFLICT),
966 ],
967 );
968
969 check_git_statuses(
970 &repo_snapshots,
971 &worktree_snapshot,
972 &[
973 ("a/b/c1.txt", ADDED),
974 ("a/b/c2.txt", GitSummary::UNCHANGED),
975 ("a/d/e1.txt", GitSummary::UNCHANGED),
976 ("a/d/e2.txt", MODIFIED),
977 ("f/no-status.txt", GitSummary::UNCHANGED),
978 ],
979 );
980 }
981
982 #[gpui::test]
983 async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) {
984 init_test(cx);
985 let fs = FakeFs::new(cx.background_executor.clone());
986 fs.insert_tree(
987 path!("/root"),
988 json!({
989 "x": {
990 ".git": {},
991 "x1.txt": "foo",
992 "x2.txt": "bar"
993 },
994 "y": {
995 ".git": {},
996 "y1.txt": "baz",
997 "y2.txt": "qux"
998 },
999 "z": {
1000 ".git": {},
1001 "z1.txt": "quux",
1002 "z2.txt": "quuux"
1003 }
1004 }),
1005 )
1006 .await;
1007
1008 fs.set_status_for_repo(
1009 Path::new(path!("/root/x/.git")),
1010 &[("x1.txt", StatusCode::Added.index())],
1011 );
1012 fs.set_status_for_repo(
1013 Path::new(path!("/root/y/.git")),
1014 &[
1015 ("y1.txt", CONFLICT),
1016 ("y2.txt", StatusCode::Modified.index()),
1017 ],
1018 );
1019 fs.set_status_for_repo(
1020 Path::new(path!("/root/z/.git")),
1021 &[("z2.txt", StatusCode::Modified.index())],
1022 );
1023
1024 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
1025 cx.executor().run_until_parked();
1026
1027 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
1028 (
1029 project.git_store().read(cx).repo_snapshots(cx),
1030 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
1031 )
1032 });
1033
1034 check_git_statuses(
1035 &repo_snapshots,
1036 &worktree_snapshot,
1037 &[("x", ADDED), ("x/x1.txt", ADDED)],
1038 );
1039
1040 check_git_statuses(
1041 &repo_snapshots,
1042 &worktree_snapshot,
1043 &[
1044 ("y", GitSummary::CONFLICT + MODIFIED),
1045 ("y/y1.txt", GitSummary::CONFLICT),
1046 ("y/y2.txt", MODIFIED),
1047 ],
1048 );
1049
1050 check_git_statuses(
1051 &repo_snapshots,
1052 &worktree_snapshot,
1053 &[("z", MODIFIED), ("z/z2.txt", MODIFIED)],
1054 );
1055
1056 check_git_statuses(
1057 &repo_snapshots,
1058 &worktree_snapshot,
1059 &[("x", ADDED), ("x/x1.txt", ADDED)],
1060 );
1061
1062 check_git_statuses(
1063 &repo_snapshots,
1064 &worktree_snapshot,
1065 &[
1066 ("x", ADDED),
1067 ("x/x1.txt", ADDED),
1068 ("x/x2.txt", GitSummary::UNCHANGED),
1069 ("y", GitSummary::CONFLICT + MODIFIED),
1070 ("y/y1.txt", GitSummary::CONFLICT),
1071 ("y/y2.txt", MODIFIED),
1072 ("z", MODIFIED),
1073 ("z/z1.txt", GitSummary::UNCHANGED),
1074 ("z/z2.txt", MODIFIED),
1075 ],
1076 );
1077 }
1078
1079 fn init_test(cx: &mut gpui::TestAppContext) {
1080 zlog::init_test();
1081
1082 cx.update(|cx| {
1083 let settings_store = SettingsStore::test(cx);
1084 cx.set_global(settings_store);
1085 });
1086 }
1087
1088 #[gpui::test]
1089 async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
1090 init_test(cx);
1091
1092 // Create a worktree with a git directory.
1093 let fs = FakeFs::new(cx.background_executor.clone());
1094 fs.insert_tree(
1095 path!("/root"),
1096 json!({
1097 ".git": {},
1098 "a.txt": "",
1099 "b": {
1100 "c.txt": "",
1101 },
1102 }),
1103 )
1104 .await;
1105 fs.set_head_and_index_for_repo(
1106 path!("/root/.git").as_ref(),
1107 &[("a.txt", "".into()), ("b/c.txt", "".into())],
1108 );
1109 cx.run_until_parked();
1110
1111 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1112 cx.executor().run_until_parked();
1113
1114 let (old_entry_ids, old_mtimes) = project.read_with(cx, |project, cx| {
1115 let tree = project.worktrees(cx).next().unwrap().read(cx);
1116 (
1117 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
1118 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
1119 )
1120 });
1121
1122 // Regression test: after the directory is scanned, touch the git repo's
1123 // working directory, bumping its mtime. That directory keeps its project
1124 // entry id after the directories are re-scanned.
1125 fs.touch_path(path!("/root")).await;
1126 cx.executor().run_until_parked();
1127
1128 let (new_entry_ids, new_mtimes) = project.read_with(cx, |project, cx| {
1129 let tree = project.worktrees(cx).next().unwrap().read(cx);
1130 (
1131 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
1132 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
1133 )
1134 });
1135 assert_eq!(new_entry_ids, old_entry_ids);
1136 assert_ne!(new_mtimes, old_mtimes);
1137
1138 // Regression test: changes to the git repository should still be
1139 // detected.
1140 fs.set_head_for_repo(
1141 path!("/root/.git").as_ref(),
1142 &[("a.txt", "".into()), ("b/c.txt", "something-else".into())],
1143 "deadbeef",
1144 );
1145 cx.executor().run_until_parked();
1146 cx.executor().advance_clock(Duration::from_secs(1));
1147
1148 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
1149 (
1150 project.git_store().read(cx).repo_snapshots(cx),
1151 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
1152 )
1153 });
1154
1155 check_git_statuses(
1156 &repo_snapshots,
1157 &worktree_snapshot,
1158 &[
1159 ("", MODIFIED),
1160 ("a.txt", GitSummary::UNCHANGED),
1161 ("b/c.txt", MODIFIED),
1162 ],
1163 );
1164 }
1165
1166 #[track_caller]
1167 fn check_git_statuses(
1168 repo_snapshots: &HashMap<RepositoryId, RepositorySnapshot>,
1169 worktree_snapshot: &worktree::Snapshot,
1170 expected_statuses: &[(&str, GitSummary)],
1171 ) {
1172 let mut traversal = GitTraversal::new(
1173 repo_snapshots,
1174 worktree_snapshot.traverse_from_path(true, true, false, RelPath::empty()),
1175 );
1176 let found_statuses = expected_statuses
1177 .iter()
1178 .map(|&(path, _)| {
1179 let git_entry = traversal
1180 .find(|git_entry| git_entry.path.as_ref() == rel_path(path))
1181 .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
1182 (path, git_entry.git_summary)
1183 })
1184 .collect::<Vec<_>>();
1185 pretty_assertions::assert_eq!(found_statuses, expected_statuses);
1186 }
1187}
1188
1189mod git_worktrees {
1190 use fs::{FakeFs, Fs};
1191 use gpui::TestAppContext;
1192 use project::worktrees_directory_for_repo;
1193 use serde_json::json;
1194 use settings::SettingsStore;
1195 use std::path::{Path, PathBuf};
1196 use util::{path, paths::PathStyle};
1197
1198 fn init_test(cx: &mut gpui::TestAppContext) {
1199 zlog::init_test();
1200
1201 cx.update(|cx| {
1202 let settings_store = SettingsStore::test(cx);
1203 cx.set_global(settings_store);
1204 });
1205 }
1206
1207 #[test]
1208 fn test_validate_worktree_directory() {
1209 let work_dir = Path::new("/code/my-project");
1210
1211 // Valid: sibling
1212 assert!(worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Unix).is_ok());
1213
1214 // Valid: subdirectory
1215 assert!(
1216 worktrees_directory_for_repo(work_dir, ".git/zed-worktrees", PathStyle::Unix).is_ok()
1217 );
1218 assert!(worktrees_directory_for_repo(work_dir, "my-worktrees", PathStyle::Unix).is_ok());
1219
1220 // Invalid: just ".." would resolve back to the working directory itself
1221 let err = worktrees_directory_for_repo(work_dir, "..", PathStyle::Unix).unwrap_err();
1222 assert!(err.to_string().contains("must not be \"..\""));
1223
1224 // Invalid: ".." with trailing separators
1225 let err = worktrees_directory_for_repo(work_dir, "..\\", PathStyle::Unix).unwrap_err();
1226 assert!(err.to_string().contains("must not be \"..\""));
1227 let err = worktrees_directory_for_repo(work_dir, "../", PathStyle::Unix).unwrap_err();
1228 assert!(err.to_string().contains("must not be \"..\""));
1229
1230 // Invalid: empty string would resolve to the working directory itself
1231 let err = worktrees_directory_for_repo(work_dir, "", PathStyle::Unix).unwrap_err();
1232 assert!(err.to_string().contains("must not be empty"));
1233
1234 // Invalid: absolute path
1235 let err =
1236 worktrees_directory_for_repo(work_dir, "/tmp/worktrees", PathStyle::Unix).unwrap_err();
1237 assert!(err.to_string().contains("relative path"));
1238
1239 // Invalid: "/" is absolute on Unix
1240 let err = worktrees_directory_for_repo(work_dir, "/", PathStyle::Unix).unwrap_err();
1241 assert!(err.to_string().contains("relative path"));
1242
1243 // Invalid: "///" is absolute
1244 let err = worktrees_directory_for_repo(work_dir, "///", PathStyle::Unix).unwrap_err();
1245 assert!(err.to_string().contains("relative path"));
1246
1247 // Invalid: escapes too far up
1248 let err = worktrees_directory_for_repo(work_dir, "../../other-project/wt", PathStyle::Unix)
1249 .unwrap_err();
1250 assert!(err.to_string().contains("outside"));
1251 }
1252
1253 #[test]
1254 fn test_worktree_directory_uses_remote_path_style() {
1255 let work_dir = Path::new("/home/user/dev/lsp-tests");
1256
1257 let directory =
1258 worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Unix).unwrap();
1259
1260 assert_eq!(
1261 directory,
1262 PathBuf::from("/home/user/dev/worktrees/lsp-tests")
1263 );
1264 }
1265
1266 #[gpui::test]
1267 async fn test_git_worktrees_list_and_create(cx: &mut TestAppContext) {
1268 init_test(cx);
1269 let fs = FakeFs::new(cx.background_executor.clone());
1270 fs.insert_tree(
1271 path!("/root"),
1272 json!({
1273 ".git": {},
1274 "file.txt": "content",
1275 }),
1276 )
1277 .await;
1278
1279 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1280 cx.executor().run_until_parked();
1281
1282 let repository = project.read_with(cx, |project, cx| {
1283 project.repositories(cx).values().next().unwrap().clone()
1284 });
1285
1286 let worktrees = cx
1287 .update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
1288 .await
1289 .unwrap()
1290 .unwrap();
1291 assert_eq!(worktrees.len(), 1);
1292 assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
1293
1294 let worktrees_directory = PathBuf::from(path!("/root"));
1295 let worktree_1_directory = worktrees_directory.join("feature-branch");
1296 cx.update(|cx| {
1297 repository.update(cx, |repository, _| {
1298 repository.create_worktree(
1299 git::repository::CreateWorktreeTarget::NewBranch {
1300 branch_name: "feature-branch".to_string(),
1301 base_sha: Some("abc123".to_string()),
1302 },
1303 worktree_1_directory.clone(),
1304 )
1305 })
1306 })
1307 .await
1308 .unwrap()
1309 .unwrap();
1310
1311 cx.executor().run_until_parked();
1312
1313 let worktrees = cx
1314 .update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
1315 .await
1316 .unwrap()
1317 .unwrap();
1318 assert_eq!(worktrees.len(), 2);
1319 assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
1320 assert_eq!(worktrees[1].path, worktree_1_directory);
1321 assert_eq!(
1322 worktrees[1].ref_name,
1323 Some("refs/heads/feature-branch".into())
1324 );
1325 assert_eq!(worktrees[1].sha.as_ref(), "abc123");
1326
1327 let worktree_2_directory = worktrees_directory.join("bugfix-branch");
1328 cx.update(|cx| {
1329 repository.update(cx, |repository, _| {
1330 repository.create_worktree(
1331 git::repository::CreateWorktreeTarget::NewBranch {
1332 branch_name: "bugfix-branch".to_string(),
1333 base_sha: None,
1334 },
1335 worktree_2_directory.clone(),
1336 )
1337 })
1338 })
1339 .await
1340 .unwrap()
1341 .unwrap();
1342
1343 cx.executor().run_until_parked();
1344
1345 // List worktrees — should now have main + two created
1346 let worktrees = cx
1347 .update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
1348 .await
1349 .unwrap()
1350 .unwrap();
1351 assert_eq!(worktrees.len(), 3);
1352
1353 let worktree_1 = worktrees
1354 .iter()
1355 .find(|worktree| worktree.ref_name == Some("refs/heads/feature-branch".into()))
1356 .expect("should find feature-branch worktree");
1357 assert_eq!(worktree_1.path, worktree_1_directory);
1358
1359 let worktree_2 = worktrees
1360 .iter()
1361 .find(|worktree| worktree.ref_name == Some("refs/heads/bugfix-branch".into()))
1362 .expect("should find bugfix-branch worktree");
1363 assert_eq!(worktree_2.path, worktree_2_directory);
1364 assert_eq!(worktree_2.sha.as_ref(), "fake-sha");
1365 }
1366
1367 #[gpui::test]
1368 async fn test_remove_worktree_removes_managed_parent_directories(cx: &mut TestAppContext) {
1369 init_test(cx);
1370 let fs = FakeFs::new(cx.background_executor.clone());
1371 fs.insert_tree(
1372 path!("/root"),
1373 json!({
1374 ".git": {},
1375 "file.txt": "content",
1376 }),
1377 )
1378 .await;
1379
1380 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1381 cx.executor().run_until_parked();
1382
1383 let repository = project.read_with(cx, |project, cx| {
1384 project.repositories(cx).values().next().unwrap().clone()
1385 });
1386
1387 let worktree_path = PathBuf::from(path!("/worktrees/root/feature/nested/root"));
1388 let worktree_parent = PathBuf::from(path!("/worktrees/root/feature/nested"));
1389 let worktree_intermediate_parent = PathBuf::from(path!("/worktrees/root/feature"));
1390 let worktree_base = PathBuf::from(path!("/worktrees/root"));
1391
1392 cx.update(|cx| {
1393 repository.update(cx, |repository, _| {
1394 repository.create_worktree(
1395 git::repository::CreateWorktreeTarget::NewBranch {
1396 branch_name: "feature/nested".to_string(),
1397 base_sha: Some("abc123".to_string()),
1398 },
1399 worktree_path.clone(),
1400 )
1401 })
1402 })
1403 .await
1404 .unwrap()
1405 .unwrap();
1406
1407 assert!(Fs::is_dir(fs.as_ref(), &worktree_path).await);
1408 assert!(Fs::is_dir(fs.as_ref(), &worktree_parent).await);
1409 assert!(Fs::is_dir(fs.as_ref(), &worktree_intermediate_parent).await);
1410 assert!(Fs::is_dir(fs.as_ref(), &worktree_base).await);
1411
1412 cx.update(|cx| {
1413 repository.update(cx, |repository, _| {
1414 repository.remove_worktree(worktree_path.clone(), false)
1415 })
1416 })
1417 .await
1418 .unwrap()
1419 .unwrap();
1420
1421 cx.executor().run_until_parked();
1422
1423 assert!(!Fs::is_dir(fs.as_ref(), &worktree_path).await);
1424 assert!(!Fs::is_dir(fs.as_ref(), &worktree_parent).await);
1425 assert!(!Fs::is_dir(fs.as_ref(), &worktree_intermediate_parent).await);
1426 assert!(Fs::is_dir(fs.as_ref(), &worktree_base).await);
1427 }
1428
1429 use crate::Project;
1430}
1431
1432mod trust_tests {
1433 use collections::HashSet;
1434 use fs::FakeFs;
1435 use gpui::TestAppContext;
1436 use project::trusted_worktrees::*;
1437
1438 use serde_json::json;
1439 use settings::SettingsStore;
1440 use util::path;
1441
1442 use crate::Project;
1443
1444 fn init_test(cx: &mut TestAppContext) {
1445 zlog::init_test();
1446
1447 cx.update(|cx| {
1448 let settings_store = SettingsStore::test(cx);
1449 cx.set_global(settings_store);
1450 });
1451 }
1452
1453 #[gpui::test]
1454 async fn test_repository_defaults_to_untrusted_without_trust_system(cx: &mut TestAppContext) {
1455 init_test(cx);
1456 let fs = FakeFs::new(cx.background_executor.clone());
1457 fs.insert_tree(
1458 path!("/project"),
1459 json!({
1460 ".git": {},
1461 "a.txt": "hello",
1462 }),
1463 )
1464 .await;
1465
1466 // Create project without trust system — repos should default to untrusted.
1467 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
1468 cx.executor().run_until_parked();
1469
1470 let repository = project.read_with(cx, |project, cx| {
1471 project.repositories(cx).values().next().unwrap().clone()
1472 });
1473
1474 repository.read_with(cx, |repo, _| {
1475 assert!(
1476 !repo.is_trusted(),
1477 "repository should default to untrusted when no trust system is initialized"
1478 );
1479 });
1480 }
1481
1482 #[gpui::test]
1483 async fn test_multiple_repos_trust_with_single_worktree(cx: &mut TestAppContext) {
1484 init_test(cx);
1485 let fs = FakeFs::new(cx.background_executor.clone());
1486 fs.insert_tree(
1487 path!("/project"),
1488 json!({
1489 ".git": {},
1490 "a.txt": "hello",
1491 "sub": {
1492 ".git": {},
1493 "b.txt": "world",
1494 },
1495 }),
1496 )
1497 .await;
1498
1499 cx.update(|cx| {
1500 init(DbTrustedPaths::default(), cx);
1501 });
1502
1503 let project =
1504 Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await;
1505 cx.executor().run_until_parked();
1506
1507 let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
1508 let worktree_id = worktree_store.read_with(cx, |store, cx| {
1509 store.worktrees().next().unwrap().read(cx).id()
1510 });
1511
1512 let repos = project.read_with(cx, |project, cx| {
1513 project
1514 .repositories(cx)
1515 .values()
1516 .cloned()
1517 .collect::<Vec<_>>()
1518 });
1519 assert_eq!(repos.len(), 2, "should have two repositories");
1520 for repo in &repos {
1521 repo.read_with(cx, |repo, _| {
1522 assert!(
1523 !repo.is_trusted(),
1524 "all repos should be untrusted initially"
1525 );
1526 });
1527 }
1528
1529 let trusted_worktrees = cx
1530 .update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set"));
1531 trusted_worktrees.update(cx, |store, cx| {
1532 store.trust(
1533 &worktree_store,
1534 HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1535 cx,
1536 );
1537 });
1538 cx.executor().run_until_parked();
1539
1540 for repo in &repos {
1541 repo.read_with(cx, |repo, _| {
1542 assert!(
1543 repo.is_trusted(),
1544 "all repos should be trusted after worktree is trusted"
1545 );
1546 });
1547 }
1548 }
1549
1550 #[gpui::test]
1551 async fn test_repository_trust_restrict_trust_cycle(cx: &mut TestAppContext) {
1552 init_test(cx);
1553 let fs = FakeFs::new(cx.background_executor.clone());
1554 fs.insert_tree(
1555 path!("/project"),
1556 json!({
1557 ".git": {},
1558 "a.txt": "hello",
1559 }),
1560 )
1561 .await;
1562
1563 cx.update(|cx| {
1564 project::trusted_worktrees::init(DbTrustedPaths::default(), cx);
1565 });
1566
1567 let project =
1568 Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await;
1569 cx.executor().run_until_parked();
1570
1571 let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
1572 let worktree_id = worktree_store.read_with(cx, |store, cx| {
1573 store.worktrees().next().unwrap().read(cx).id()
1574 });
1575
1576 let repository = project.read_with(cx, |project, cx| {
1577 project.repositories(cx).values().next().unwrap().clone()
1578 });
1579
1580 repository.read_with(cx, |repo, _| {
1581 assert!(!repo.is_trusted(), "repository should start untrusted");
1582 });
1583
1584 let trusted_worktrees = cx
1585 .update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set"));
1586
1587 trusted_worktrees.update(cx, |store, cx| {
1588 store.trust(
1589 &worktree_store,
1590 HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1591 cx,
1592 );
1593 });
1594 cx.executor().run_until_parked();
1595
1596 repository.read_with(cx, |repo, _| {
1597 assert!(
1598 repo.is_trusted(),
1599 "repository should be trusted after worktree is trusted"
1600 );
1601 });
1602
1603 trusted_worktrees.update(cx, |store, cx| {
1604 store.restrict(
1605 worktree_store.downgrade(),
1606 HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1607 cx,
1608 );
1609 });
1610 cx.executor().run_until_parked();
1611
1612 repository.read_with(cx, |repo, _| {
1613 assert!(
1614 !repo.is_trusted(),
1615 "repository should be untrusted after worktree is restricted"
1616 );
1617 });
1618
1619 trusted_worktrees.update(cx, |store, cx| {
1620 store.trust(
1621 &worktree_store,
1622 HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1623 cx,
1624 );
1625 });
1626 cx.executor().run_until_parked();
1627
1628 repository.read_with(cx, |repo, _| {
1629 assert!(
1630 repo.is_trusted(),
1631 "repository should be trusted again after second trust"
1632 );
1633 });
1634 }
1635}
1636
1637mod resolve_worktree_tests {
1638 use fs::FakeFs;
1639 use gpui::TestAppContext;
1640 use project::{
1641 git_store::resolve_git_worktree_to_main_repo, linked_worktree_short_name,
1642 repo_identity_path,
1643 };
1644 use serde_json::json;
1645 use std::path::{Path, PathBuf};
1646
1647 #[gpui::test]
1648 async fn test_resolve_git_worktree_to_main_repo(cx: &mut TestAppContext) {
1649 let fs = FakeFs::new(cx.executor());
1650 // Set up a main repo with a worktree entry
1651 fs.insert_tree(
1652 "/main-repo",
1653 json!({
1654 ".git": {
1655 "worktrees": {
1656 "feature": {
1657 "commondir": "../../",
1658 "HEAD": "ref: refs/heads/feature"
1659 }
1660 }
1661 },
1662 "src": { "main.rs": "" }
1663 }),
1664 )
1665 .await;
1666 // Set up a worktree checkout pointing back to the main repo
1667 fs.insert_tree(
1668 "/worktree-checkout",
1669 json!({
1670 ".git": "gitdir: /main-repo/.git/worktrees/feature",
1671 "src": { "main.rs": "" }
1672 }),
1673 )
1674 .await;
1675
1676 let result =
1677 resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/worktree-checkout")).await;
1678 assert_eq!(result, Some(PathBuf::from("/main-repo")));
1679 }
1680
1681 #[gpui::test]
1682 async fn test_resolve_git_worktree_normal_repo_returns_none(cx: &mut TestAppContext) {
1683 let fs = FakeFs::new(cx.executor());
1684 fs.insert_tree(
1685 "/repo",
1686 json!({
1687 ".git": {},
1688 "src": { "main.rs": "" }
1689 }),
1690 )
1691 .await;
1692
1693 let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/repo")).await;
1694 assert_eq!(result, None);
1695 }
1696
1697 #[gpui::test]
1698 async fn test_resolve_git_worktree_bare_repo_identity_path(cx: &mut TestAppContext) {
1699 let fs = FakeFs::new(cx.executor());
1700 fs.insert_tree(
1701 "/monty/.bare",
1702 json!({
1703 "worktrees": {
1704 "feature-a": {
1705 "commondir": "../../",
1706 "HEAD": "ref: refs/heads/feature-a"
1707 }
1708 }
1709 }),
1710 )
1711 .await;
1712 fs.insert_tree(
1713 "/monty/feature-a",
1714 json!({
1715 ".git": "gitdir: /monty/.bare/worktrees/feature-a",
1716 "src": { "main.rs": "" }
1717 }),
1718 )
1719 .await;
1720
1721 let result =
1722 resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/monty/feature-a")).await;
1723 assert_eq!(result, Some(PathBuf::from("/monty")));
1724 }
1725
1726 #[gpui::test]
1727 async fn test_resolve_git_worktree_no_git_returns_none(cx: &mut TestAppContext) {
1728 let fs = FakeFs::new(cx.executor());
1729 fs.insert_tree(
1730 "/plain",
1731 json!({
1732 "src": { "main.rs": "" }
1733 }),
1734 )
1735 .await;
1736
1737 let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/plain")).await;
1738 assert_eq!(result, None);
1739 }
1740
1741 #[gpui::test]
1742 async fn test_resolve_git_worktree_nonexistent_returns_none(cx: &mut TestAppContext) {
1743 let fs = FakeFs::new(cx.executor());
1744
1745 let result =
1746 resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/does-not-exist")).await;
1747 assert_eq!(result, None);
1748 }
1749
1750 #[test]
1751 fn test_repo_identity_path() {
1752 let examples = [
1753 // Normal checkout: `.git` starts with `.`, so parent is the worktree
1754 ("/home/bob/zed/.git", "/home/bob/zed"),
1755 // Bare clone named `.bare`: starts with `.`, so parent is the project dir
1756 ("/repos/project/.bare", "/repos/project"),
1757 // Bare clone with `.git` extension: does not start with `.`, kept as-is
1758 ("/repos/zed.git", "/repos/zed.git"),
1759 // Bare clone with arbitrary plain name: kept as-is
1760 ("/repos/project", "/repos/project"),
1761 ];
1762 for (common_dir, expected) in examples {
1763 assert_eq!(
1764 repo_identity_path(Path::new(common_dir)),
1765 Path::new(expected),
1766 "identity path for common_dir {common_dir:?} should be {expected:?}"
1767 );
1768 }
1769 }
1770
1771 #[test]
1772 fn test_linked_worktree_short_name() {
1773 let examples = [
1774 (
1775 "/home/bob/zed",
1776 "/home/bob/worktrees/olivetti/zed",
1777 Some("olivetti".into()),
1778 ),
1779 ("/home/bob/zed", "/home/bob/zed2", Some("zed2".into())),
1780 (
1781 "/home/bob/zed",
1782 "/home/bob/worktrees/zed/selectric",
1783 Some("selectric".into()),
1784 ),
1785 ("/home/bob/zed", "/home/bob/zed", None),
1786 ];
1787 for (main_worktree_path, linked_worktree_path, expected) in examples {
1788 let short_name = linked_worktree_short_name(
1789 Path::new(main_worktree_path),
1790 Path::new(linked_worktree_path),
1791 );
1792 assert_eq!(
1793 short_name, expected,
1794 "short name for {linked_worktree_path:?}, linked worktree of {main_worktree_path:?}, should be {expected:?}"
1795 );
1796 }
1797 }
1798}
1799