1use std::path::{self, Path, PathBuf};
2
3use call::ActiveCall;
4use client::RECEIVE_TIMEOUT;
5use collections::HashMap;
6use git::{
7 Oid,
8 repository::{CommitData, RepoPath, Worktree as GitWorktree},
9 status::{DiffStat, FileStatus, StatusCode, TrackedStatus},
10};
11use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff};
12use gpui::{AppContext as _, BackgroundExecutor, SharedString, TestAppContext, VisualTestContext};
13use project::{
14 ProjectPath,
15 git_store::{CommitDataState, Repository},
16};
17use serde_json::json;
18
19use util::{path, rel_path::rel_path};
20use workspace::{MultiWorkspace, Workspace};
21
22use crate::TestServer;
23
24#[gpui::test]
25async fn test_root_repo_common_dir_sync(
26 executor: BackgroundExecutor,
27 cx_a: &mut TestAppContext,
28 cx_b: &mut TestAppContext,
29) {
30 let mut server = TestServer::start(executor.clone()).await;
31 let client_a = server.create_client(cx_a, "user_a").await;
32 let client_b = server.create_client(cx_b, "user_b").await;
33 server
34 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
35 .await;
36 let active_call_a = cx_a.read(ActiveCall::global);
37
38 // Set up a project whose root IS a git repository.
39 client_a
40 .fs()
41 .insert_tree(
42 path!("/project"),
43 json!({ ".git": {}, "file.txt": "content" }),
44 )
45 .await;
46
47 let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
48 executor.run_until_parked();
49
50 // Host should see root_repo_common_dir pointing to .git at the root.
51 let host_common_dir = project_a.read_with(cx_a, |project, cx| {
52 let worktree = project.worktrees(cx).next().unwrap();
53 worktree.read(cx).snapshot().root_repo_common_dir().cloned()
54 });
55 assert_eq!(
56 host_common_dir.as_deref(),
57 Some(path::Path::new(path!("/project/.git"))),
58 );
59
60 // Share the project and have client B join.
61 let project_id = active_call_a
62 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
63 .await
64 .unwrap();
65 let project_b = client_b.join_remote_project(project_id, cx_b).await;
66 executor.run_until_parked();
67
68 // Guest should see the same root_repo_common_dir as the host.
69 let guest_common_dir = project_b.read_with(cx_b, |project, cx| {
70 let worktree = project.worktrees(cx).next().unwrap();
71 worktree.read(cx).snapshot().root_repo_common_dir().cloned()
72 });
73 assert_eq!(
74 guest_common_dir, host_common_dir,
75 "guest should see the same root_repo_common_dir as host",
76 );
77}
78
79fn collect_diff_stats<C: gpui::AppContext>(
80 panel: &gpui::Entity<GitPanel>,
81 cx: &C,
82) -> HashMap<RepoPath, DiffStat> {
83 panel.read_with(cx, |panel, cx| {
84 let Some(repo) = panel.active_repository() else {
85 return HashMap::default();
86 };
87 let snapshot = repo.read(cx).snapshot();
88 let mut stats = HashMap::default();
89 for entry in snapshot.statuses_by_path.iter() {
90 if let Some(diff_stat) = entry.diff_stat {
91 stats.insert(entry.repo_path.clone(), diff_stat);
92 }
93 }
94 stats
95 })
96}
97
98async fn load_commit_data_batch(
99 repository: &gpui::Entity<Repository>,
100 shas: &[Oid],
101 executor: &BackgroundExecutor,
102 cx: &mut TestAppContext,
103) -> HashMap<Oid, CommitData> {
104 let states = cx.update(|cx| {
105 shas.iter()
106 .map(|sha| {
107 (
108 *sha,
109 repository.update(cx, |repository, cx| {
110 repository.fetch_commit_data(*sha, true, cx).clone()
111 }),
112 )
113 })
114 .collect::<Vec<_>>()
115 });
116
117 executor.run_until_parked();
118
119 let mut commit_data = HashMap::default();
120 for (sha, state) in states {
121 let data = match state {
122 CommitDataState::Loaded(data) => data.as_ref().clone(),
123 CommitDataState::Loading(Some(shared)) => shared.await.unwrap().as_ref().clone(),
124 CommitDataState::Loading(None) => {
125 panic!("fetch_commit_data(..., true) should return an await-result state")
126 }
127 };
128 commit_data.insert(sha, data);
129 }
130
131 commit_data
132}
133
134fn branch_list_snapshot(
135 project: &gpui::Entity<project::Project>,
136 cx: &mut TestAppContext,
137) -> (Option<String>, Vec<String>) {
138 project.read_with(cx, |project, cx| {
139 let repos = project.repositories(cx);
140 assert_eq!(repos.len(), 1, "project should have exactly 1 repository");
141 let repo = repos.values().next().unwrap();
142 let snapshot = repo.read(cx).snapshot();
143 (
144 snapshot
145 .branch
146 .as_ref()
147 .map(|branch| branch.name().to_string()),
148 snapshot
149 .branch_list
150 .iter()
151 .map(|branch| branch.ref_name.to_string())
152 .collect(),
153 )
154 })
155}
156
157fn assert_remote_cache_matches_local_cache(
158 local_repository: &gpui::Entity<Repository>,
159 remote_repository: &gpui::Entity<Repository>,
160 cx_local: &mut TestAppContext,
161 cx_remote: &mut TestAppContext,
162) {
163 let local_cache = cx_local.update(|cx| {
164 local_repository.update(cx, |repository, _| repository.loaded_commit_data_for_test())
165 });
166 let remote_cache = cx_remote.update(|cx| {
167 remote_repository.update(cx, |repository, _| repository.loaded_commit_data_for_test())
168 });
169
170 for (sha, remote_commit_data) in &remote_cache {
171 let local_commit_data = local_cache
172 .get(sha)
173 .unwrap_or_else(|| panic!("local cache missing commit data for {sha}"));
174 assert_eq!(
175 local_commit_data.sha, remote_commit_data.sha,
176 "local and remote cache should agree on sha for {sha}"
177 );
178 assert_eq!(
179 local_commit_data.parents, remote_commit_data.parents,
180 "local and remote cache should agree on parents for {sha}"
181 );
182 assert_eq!(
183 local_commit_data.author_name, remote_commit_data.author_name,
184 "local and remote cache should agree on author_name for {sha}"
185 );
186 assert_eq!(
187 local_commit_data.author_email, remote_commit_data.author_email,
188 "local and remote cache should agree on author_email for {sha}"
189 );
190 assert_eq!(
191 local_commit_data.commit_timestamp, remote_commit_data.commit_timestamp,
192 "local and remote cache should agree on commit_timestamp for {sha}"
193 );
194 assert_eq!(
195 local_commit_data.subject, remote_commit_data.subject,
196 "local and remote cache should agree on subject for {sha}"
197 );
198 assert_eq!(
199 local_commit_data.message, remote_commit_data.message,
200 "local and remote cache should agree on message for {sha}"
201 );
202 }
203}
204
205#[gpui::test]
206async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
207 let mut server = TestServer::start(cx_a.background_executor.clone()).await;
208 let client_a = server.create_client(cx_a, "user_a").await;
209 let client_b = server.create_client(cx_b, "user_b").await;
210 cx_a.set_name("cx_a");
211 cx_b.set_name("cx_b");
212
213 server
214 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
215 .await;
216
217 client_a
218 .fs()
219 .insert_tree(
220 path!("/a"),
221 json!({
222 ".git": {},
223 "changed.txt": "after\n",
224 "unchanged.txt": "unchanged\n",
225 "created.txt": "created\n",
226 "secret.pem": "secret-changed\n",
227 }),
228 )
229 .await;
230
231 client_a.fs().set_head_and_index_for_repo(
232 Path::new(path!("/a/.git")),
233 &[
234 ("changed.txt", "before\n".to_string()),
235 ("unchanged.txt", "unchanged\n".to_string()),
236 ("deleted.txt", "deleted\n".to_string()),
237 ("secret.pem", "shh\n".to_string()),
238 ],
239 );
240 let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
241 let active_call_a = cx_a.read(ActiveCall::global);
242 let project_id = active_call_a
243 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
244 .await
245 .unwrap();
246
247 cx_b.update(editor::init);
248 cx_b.update(git_ui::init);
249 let project_b = client_b.join_remote_project(project_id, cx_b).await;
250 let window_b = cx_b.add_window(|window, cx| {
251 let workspace = cx.new(|cx| {
252 Workspace::new(
253 None,
254 project_b.clone(),
255 client_b.app_state.clone(),
256 window,
257 cx,
258 )
259 });
260 MultiWorkspace::new(workspace, window, cx)
261 });
262 let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b);
263 let workspace_b = window_b
264 .root(cx_b)
265 .unwrap()
266 .read_with(cx_b, |multi_workspace, _| {
267 multi_workspace.workspace().clone()
268 });
269
270 cx_b.update(|window, cx| {
271 window
272 .focused(cx)
273 .unwrap()
274 .dispatch_action(&git_ui::project_diff::Diff, window, cx)
275 });
276 let diff = workspace_b.update(cx_b, |workspace, cx| {
277 workspace.active_item(cx).unwrap().act_as::<ProjectDiff>(cx)
278 });
279 let diff = diff.unwrap();
280 cx_b.run_until_parked();
281
282 diff.update(cx_b, |diff, cx| {
283 assert_eq!(
284 diff.excerpt_paths(cx),
285 vec![
286 rel_path("changed.txt").into_arc(),
287 rel_path("deleted.txt").into_arc(),
288 rel_path("created.txt").into_arc()
289 ]
290 );
291 });
292
293 client_a
294 .fs()
295 .insert_tree(
296 path!("/a"),
297 json!({
298 ".git": {},
299 "changed.txt": "before\n",
300 "unchanged.txt": "changed\n",
301 "created.txt": "created\n",
302 "secret.pem": "secret-changed\n",
303 }),
304 )
305 .await;
306 cx_b.run_until_parked();
307
308 project_b.update(cx_b, |project, cx| {
309 let project_path = ProjectPath {
310 worktree_id,
311 path: rel_path("unchanged.txt").into(),
312 };
313 let status = project.project_path_git_status(&project_path, cx);
314 assert_eq!(
315 status.unwrap(),
316 FileStatus::Tracked(TrackedStatus {
317 worktree_status: StatusCode::Modified,
318 index_status: StatusCode::Unmodified,
319 })
320 );
321 });
322
323 diff.update(cx_b, |diff, cx| {
324 assert_eq!(
325 diff.excerpt_paths(cx),
326 vec![
327 rel_path("deleted.txt").into_arc(),
328 rel_path("unchanged.txt").into_arc(),
329 rel_path("created.txt").into_arc()
330 ]
331 );
332 });
333}
334
335#[gpui::test]
336async fn test_remote_git_worktrees(
337 executor: BackgroundExecutor,
338 cx_a: &mut TestAppContext,
339 cx_b: &mut TestAppContext,
340) {
341 let mut server = TestServer::start(executor.clone()).await;
342 let client_a = server.create_client(cx_a, "user_a").await;
343 let client_b = server.create_client(cx_b, "user_b").await;
344 server
345 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
346 .await;
347 let active_call_a = cx_a.read(ActiveCall::global);
348
349 client_a
350 .fs()
351 .insert_tree(
352 path!("/project"),
353 json!({ ".git": {}, "file.txt": "content" }),
354 )
355 .await;
356
357 let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
358
359 let project_id = active_call_a
360 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
361 .await
362 .unwrap();
363 let project_b = client_b.join_remote_project(project_id, cx_b).await;
364
365 executor.run_until_parked();
366
367 let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
368
369 // Initially only the main worktree (the repo itself) should be present
370 let worktrees = cx_b
371 .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
372 .await
373 .unwrap()
374 .unwrap();
375 assert_eq!(worktrees.len(), 1);
376 assert_eq!(worktrees[0].path, PathBuf::from(path!("/project")));
377
378 // Client B creates a git worktree via the remote project
379 let worktree_directory = PathBuf::from(path!("/project"));
380 cx_b.update(|cx| {
381 repo_b.update(cx, |repository, _| {
382 repository.create_worktree(
383 git::repository::CreateWorktreeTarget::NewBranch {
384 branch_name: "feature-branch".to_string(),
385 base_sha: Some("abc123".to_string()),
386 },
387 worktree_directory.join("feature-branch"),
388 )
389 })
390 })
391 .await
392 .unwrap()
393 .unwrap();
394
395 executor.run_until_parked();
396
397 // Client B lists worktrees — should see main + the one just created
398 let worktrees = cx_b
399 .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
400 .await
401 .unwrap()
402 .unwrap();
403 assert_eq!(worktrees.len(), 2);
404 assert_eq!(worktrees[0].path, PathBuf::from(path!("/project")));
405 assert_eq!(worktrees[1].path, worktree_directory.join("feature-branch"));
406 assert_eq!(
407 worktrees[1].ref_name,
408 Some("refs/heads/feature-branch".into())
409 );
410 assert_eq!(worktrees[1].sha.as_ref(), "abc123");
411
412 // Verify from the host side that the worktree was actually created
413 let host_worktrees = {
414 let repo_a = cx_a.update(|cx| {
415 project_a
416 .read(cx)
417 .repositories(cx)
418 .values()
419 .next()
420 .unwrap()
421 .clone()
422 });
423 cx_a.update(|cx| repo_a.update(cx, |repository, _| repository.worktrees()))
424 .await
425 .unwrap()
426 .unwrap()
427 };
428 assert_eq!(host_worktrees.len(), 2);
429 assert_eq!(host_worktrees[0].path, PathBuf::from(path!("/project")));
430 assert_eq!(
431 host_worktrees[1].path,
432 worktree_directory.join("feature-branch")
433 );
434
435 // Client B creates a second git worktree without an explicit commit
436 cx_b.update(|cx| {
437 repo_b.update(cx, |repository, _| {
438 repository.create_worktree(
439 git::repository::CreateWorktreeTarget::NewBranch {
440 branch_name: "bugfix-branch".to_string(),
441 base_sha: None,
442 },
443 worktree_directory.join("bugfix-branch"),
444 )
445 })
446 })
447 .await
448 .unwrap()
449 .unwrap();
450
451 executor.run_until_parked();
452
453 // Client B lists worktrees — should now have main + two created
454 let worktrees = cx_b
455 .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
456 .await
457 .unwrap()
458 .unwrap();
459 assert_eq!(worktrees.len(), 3);
460
461 let feature_worktree = worktrees
462 .iter()
463 .find(|worktree| worktree.ref_name == Some("refs/heads/feature-branch".into()))
464 .expect("should find feature-branch worktree");
465 assert_eq!(
466 feature_worktree.path,
467 worktree_directory.join("feature-branch")
468 );
469
470 let bugfix_worktree = worktrees
471 .iter()
472 .find(|worktree| worktree.ref_name == Some("refs/heads/bugfix-branch".into()))
473 .expect("should find bugfix-branch worktree");
474 assert_eq!(
475 bugfix_worktree.path,
476 worktree_directory.join("bugfix-branch")
477 );
478 assert_eq!(bugfix_worktree.sha.as_ref(), "fake-sha");
479
480 // Client B (guest) attempts to rename a worktree. This should fail
481 // because worktree renaming is not forwarded through collab
482 let rename_result = cx_b
483 .update(|cx| {
484 repo_b.update(cx, |repository, _| {
485 repository.rename_worktree(
486 worktree_directory.join("feature-branch"),
487 worktree_directory.join("renamed-branch"),
488 )
489 })
490 })
491 .await
492 .unwrap();
493 assert!(
494 rename_result.is_err(),
495 "Guest should not be able to rename worktrees via collab"
496 );
497
498 executor.run_until_parked();
499
500 // Verify worktrees are unchanged — still 3
501 let worktrees = cx_b
502 .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
503 .await
504 .unwrap()
505 .unwrap();
506 assert_eq!(
507 worktrees.len(),
508 3,
509 "Worktree count should be unchanged after failed rename"
510 );
511
512 // Client B (guest) attempts to remove a worktree. This should fail
513 // because worktree removal is not forwarded through collab
514 let remove_result = cx_b
515 .update(|cx| {
516 repo_b.update(cx, |repository, _| {
517 repository.remove_worktree(worktree_directory.join("feature-branch"), false)
518 })
519 })
520 .await
521 .unwrap();
522 assert!(
523 remove_result.is_err(),
524 "Guest should not be able to remove worktrees via collab"
525 );
526
527 executor.run_until_parked();
528
529 // Verify worktrees are unchanged — still 3
530 let worktrees = cx_b
531 .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
532 .await
533 .unwrap()
534 .unwrap();
535 assert_eq!(
536 worktrees.len(),
537 3,
538 "Worktree count should be unchanged after failed removal"
539 );
540}
541
542#[gpui::test]
543async fn test_remote_git_head_sha(
544 executor: BackgroundExecutor,
545 cx_a: &mut TestAppContext,
546 cx_b: &mut TestAppContext,
547) {
548 let mut server = TestServer::start(executor.clone()).await;
549 let client_a = server.create_client(cx_a, "user_a").await;
550 let client_b = server.create_client(cx_b, "user_b").await;
551 server
552 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
553 .await;
554 let active_call_a = cx_a.read(ActiveCall::global);
555
556 client_a
557 .fs()
558 .insert_tree(
559 path!("/project"),
560 json!({ ".git": {}, "file.txt": "content" }),
561 )
562 .await;
563
564 let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
565 let local_head_sha = cx_a.update(|cx| {
566 project_a
567 .read(cx)
568 .active_repository(cx)
569 .unwrap()
570 .update(cx, |repository, _| repository.head_sha())
571 });
572 let local_head_sha = local_head_sha.await.unwrap().unwrap();
573
574 let project_id = active_call_a
575 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
576 .await
577 .unwrap();
578 let project_b = client_b.join_remote_project(project_id, cx_b).await;
579
580 executor.run_until_parked();
581
582 let remote_head_sha = cx_b.update(|cx| {
583 project_b
584 .read(cx)
585 .active_repository(cx)
586 .unwrap()
587 .update(cx, |repository, _| repository.head_sha())
588 });
589 let remote_head_sha = remote_head_sha.await.unwrap();
590
591 assert_eq!(remote_head_sha.unwrap(), local_head_sha);
592}
593
594#[gpui::test]
595async fn test_remote_git_commit_data_batches(
596 executor: BackgroundExecutor,
597 cx_a: &mut TestAppContext,
598 cx_b: &mut TestAppContext,
599) {
600 let mut server = TestServer::start(executor.clone()).await;
601 let client_a = server.create_client(cx_a, "user_a").await;
602 let client_b = server.create_client(cx_b, "user_b").await;
603 server
604 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
605 .await;
606 let active_call_a = cx_a.read(ActiveCall::global);
607
608 client_a
609 .fs()
610 .insert_tree(
611 path!("/project"),
612 json!({ ".git": {}, "file.txt": "content" }),
613 )
614 .await;
615
616 let commit_shas = [
617 "0123456789abcdef0123456789abcdef01234567"
618 .parse::<Oid>()
619 .unwrap(),
620 "1111111111111111111111111111111111111111"
621 .parse::<Oid>()
622 .unwrap(),
623 "2222222222222222222222222222222222222222"
624 .parse::<Oid>()
625 .unwrap(),
626 "3333333333333333333333333333333333333333"
627 .parse::<Oid>()
628 .unwrap(),
629 ];
630
631 client_a.fs().set_commit_data(
632 Path::new(path!("/project/.git")),
633 commit_shas.iter().enumerate().map(|(index, sha)| {
634 (
635 CommitData {
636 sha: *sha,
637 parents: Default::default(),
638 author_name: SharedString::from(format!("Author {index}")),
639 author_email: SharedString::from(format!("author{index}@example.com")),
640 commit_timestamp: 1_700_000_000 + index as i64,
641 subject: SharedString::from(format!("Subject {index}")),
642 message: SharedString::from(format!("Subject {index}\n\nBody {index}")),
643 },
644 false,
645 )
646 }),
647 );
648
649 let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
650 executor.run_until_parked();
651
652 let repo_a = cx_a.update(|cx| project_a.read(cx).active_repository(cx).unwrap());
653
654 let primed_before = load_commit_data_batch(&repo_a, &commit_shas[..2], &executor, cx_a).await;
655 assert_eq!(
656 primed_before.len(),
657 2,
658 "host should prime two commits before sharing"
659 );
660
661 let project_id = active_call_a
662 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
663 .await
664 .unwrap();
665 let project_b = client_b.join_remote_project(project_id, cx_b).await;
666
667 executor.run_until_parked();
668
669 let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
670
671 let remote_batch_one =
672 load_commit_data_batch(&repo_b, &commit_shas[..3], &executor, cx_b).await;
673 assert_eq!(remote_batch_one.len(), 3);
674 for (index, sha) in commit_shas[..3].iter().enumerate() {
675 let commit_data = remote_batch_one.get(sha).unwrap();
676 assert_eq!(commit_data.sha, *sha);
677 assert_eq!(commit_data.subject.as_ref(), format!("Subject {index}"));
678 assert_eq!(
679 commit_data.message.as_ref(),
680 format!("Subject {index}\n\nBody {index}")
681 );
682 }
683
684 let primed_after = load_commit_data_batch(&repo_a, &commit_shas[2..], &executor, cx_a).await;
685 assert_eq!(
686 primed_after.len(),
687 2,
688 "host should prime remaining commits after remote fetches"
689 );
690
691 let remote_batch_two =
692 load_commit_data_batch(&repo_b, &commit_shas[1..], &executor, cx_b).await;
693 assert_eq!(remote_batch_two.len(), 3);
694
695 assert_remote_cache_matches_local_cache(&repo_a, &repo_b, cx_a, cx_b);
696}
697
698#[gpui::test]
699async fn test_branch_list_sync(
700 executor: BackgroundExecutor,
701 cx_a: &mut TestAppContext,
702 cx_b: &mut TestAppContext,
703) {
704 let mut server = TestServer::start(executor.clone()).await;
705 let client_a = server.create_client(cx_a, "user_a").await;
706 let client_b = server.create_client(cx_b, "user_b").await;
707 server
708 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
709 .await;
710 let active_call_a = cx_a.read(ActiveCall::global);
711
712 client_a
713 .fs()
714 .insert_tree(
715 path!("/project"),
716 json!({ ".git": {}, "file.txt": "content" }),
717 )
718 .await;
719 client_a.fs().insert_branches(
720 Path::new(path!("/project/.git")),
721 &["main", "feature-1", "feature-2"],
722 );
723
724 let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
725 executor.run_until_parked();
726
727 let host_snapshot = branch_list_snapshot(&project_a, cx_a);
728 assert_eq!(host_snapshot.0.as_deref(), Some("main"));
729 assert_eq!(
730 host_snapshot.1,
731 vec![
732 "refs/heads/feature-1".to_string(),
733 "refs/heads/feature-2".to_string(),
734 "refs/heads/main".to_string(),
735 ]
736 );
737
738 let project_id = active_call_a
739 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
740 .await
741 .unwrap();
742 let project_b = client_b.join_remote_project(project_id, cx_b).await;
743
744 executor.run_until_parked();
745
746 let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
747
748 cx_b.update(|cx| {
749 repo_b.update(cx, |repository, _cx| {
750 repository.create_branch("totally-new-branch".to_string(), None)
751 })
752 })
753 .await
754 .unwrap()
755 .unwrap();
756
757 cx_b.update(|cx| {
758 repo_b.update(cx, |repository, _cx| {
759 repository.change_branch("totally-new-branch".to_string())
760 })
761 })
762 .await
763 .unwrap()
764 .unwrap();
765
766 executor.run_until_parked();
767
768 let host_snapshot_after_update = branch_list_snapshot(&project_a, cx_a);
769 assert_eq!(
770 host_snapshot_after_update.0.as_deref(),
771 Some("totally-new-branch")
772 );
773 assert_eq!(
774 host_snapshot_after_update.1,
775 vec![
776 "refs/heads/feature-1".to_string(),
777 "refs/heads/feature-2".to_string(),
778 "refs/heads/main".to_string(),
779 "refs/heads/totally-new-branch".to_string(),
780 ]
781 );
782
783 let guest_snapshot_after_update = branch_list_snapshot(&project_b, cx_b);
784 assert_eq!(guest_snapshot_after_update, host_snapshot_after_update);
785}
786
787#[gpui::test]
788async fn test_linked_worktrees_sync(
789 executor: BackgroundExecutor,
790 cx_a: &mut TestAppContext,
791 cx_b: &mut TestAppContext,
792 cx_c: &mut TestAppContext,
793) {
794 let mut server = TestServer::start(executor.clone()).await;
795 let client_a = server.create_client(cx_a, "user_a").await;
796 let client_b = server.create_client(cx_b, "user_b").await;
797 let client_c = server.create_client(cx_c, "user_c").await;
798 server
799 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
800 .await;
801 let active_call_a = cx_a.read(ActiveCall::global);
802
803 // Set up a git repo with two linked worktrees already present.
804 client_a
805 .fs()
806 .insert_tree(
807 path!("/project"),
808 json!({ ".git": {}, "file.txt": "content" }),
809 )
810 .await;
811
812 let fs = client_a.fs();
813 fs.add_linked_worktree_for_repo(
814 Path::new(path!("/project/.git")),
815 true,
816 GitWorktree {
817 path: PathBuf::from(path!("/worktrees/feature-branch")),
818 ref_name: Some("refs/heads/feature-branch".into()),
819 sha: "bbb222".into(),
820 is_main: false,
821 is_bare: false,
822 },
823 )
824 .await;
825 fs.add_linked_worktree_for_repo(
826 Path::new(path!("/project/.git")),
827 true,
828 GitWorktree {
829 path: PathBuf::from(path!("/worktrees/bugfix-branch")),
830 ref_name: Some("refs/heads/bugfix-branch".into()),
831 sha: "ccc333".into(),
832 is_main: false,
833 is_bare: false,
834 },
835 )
836 .await;
837
838 let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
839
840 // Wait for git scanning to complete on the host.
841 executor.run_until_parked();
842
843 // Verify the host sees 2 linked worktrees (main worktree is filtered out).
844 let host_linked = project_a.read_with(cx_a, |project, cx| {
845 let repos = project.repositories(cx);
846 assert_eq!(repos.len(), 1, "host should have exactly 1 repository");
847 let repo = repos.values().next().unwrap();
848 repo.read(cx).linked_worktrees().to_vec()
849 });
850 assert_eq!(
851 host_linked.len(),
852 2,
853 "host should have 2 linked worktrees (main filtered out)"
854 );
855 assert_eq!(
856 host_linked[0].path,
857 PathBuf::from(path!("/worktrees/bugfix-branch"))
858 );
859 assert_eq!(
860 host_linked[0].ref_name,
861 Some("refs/heads/bugfix-branch".into())
862 );
863 assert_eq!(host_linked[0].sha.as_ref(), "ccc333");
864 assert_eq!(
865 host_linked[1].path,
866 PathBuf::from(path!("/worktrees/feature-branch"))
867 );
868 assert_eq!(
869 host_linked[1].ref_name,
870 Some("refs/heads/feature-branch".into())
871 );
872 assert_eq!(host_linked[1].sha.as_ref(), "bbb222");
873
874 // Share the project and have client B join.
875 let project_id = active_call_a
876 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
877 .await
878 .unwrap();
879 let project_b = client_b.join_remote_project(project_id, cx_b).await;
880
881 executor.run_until_parked();
882
883 // Verify the guest sees the same linked worktrees as the host.
884 let guest_linked = project_b.read_with(cx_b, |project, cx| {
885 let repos = project.repositories(cx);
886 assert_eq!(repos.len(), 1, "guest should have exactly 1 repository");
887 let repo = repos.values().next().unwrap();
888 repo.read(cx).linked_worktrees().to_vec()
889 });
890 assert_eq!(
891 guest_linked, host_linked,
892 "guest's linked_worktrees should match host's after initial sync"
893 );
894
895 // Now mutate: add a third linked worktree on the host side.
896 client_a
897 .fs()
898 .add_linked_worktree_for_repo(
899 Path::new(path!("/project/.git")),
900 true,
901 GitWorktree {
902 path: PathBuf::from(path!("/worktrees/hotfix-branch")),
903 ref_name: Some("refs/heads/hotfix-branch".into()),
904 sha: "ddd444".into(),
905 is_main: false,
906 is_bare: false,
907 },
908 )
909 .await;
910
911 // Wait for the host to re-scan and propagate the update.
912 executor.run_until_parked();
913
914 // Verify host now sees 3 linked worktrees.
915 let host_linked_updated = project_a.read_with(cx_a, |project, cx| {
916 let repos = project.repositories(cx);
917 let repo = repos.values().next().unwrap();
918 repo.read(cx).linked_worktrees().to_vec()
919 });
920 assert_eq!(
921 host_linked_updated.len(),
922 3,
923 "host should now have 3 linked worktrees"
924 );
925 assert_eq!(
926 host_linked_updated[2].path,
927 PathBuf::from(path!("/worktrees/hotfix-branch"))
928 );
929
930 // Verify the guest also received the update.
931 let guest_linked_updated = project_b.read_with(cx_b, |project, cx| {
932 let repos = project.repositories(cx);
933 let repo = repos.values().next().unwrap();
934 repo.read(cx).linked_worktrees().to_vec()
935 });
936 assert_eq!(
937 guest_linked_updated, host_linked_updated,
938 "guest's linked_worktrees should match host's after update"
939 );
940
941 // Now mutate: remove one linked worktree from the host side.
942 client_a
943 .fs()
944 .remove_worktree_for_repo(
945 Path::new(path!("/project/.git")),
946 true,
947 "refs/heads/bugfix-branch",
948 )
949 .await;
950
951 executor.run_until_parked();
952
953 // Verify host now sees 2 linked worktrees (feature-branch and hotfix-branch).
954 let host_linked_after_removal = project_a.read_with(cx_a, |project, cx| {
955 let repos = project.repositories(cx);
956 let repo = repos.values().next().unwrap();
957 repo.read(cx).linked_worktrees().to_vec()
958 });
959 assert_eq!(
960 host_linked_after_removal.len(),
961 2,
962 "host should have 2 linked worktrees after removal"
963 );
964 assert!(
965 host_linked_after_removal
966 .iter()
967 .all(|wt| wt.ref_name != Some("refs/heads/bugfix-branch".into())),
968 "bugfix-branch should have been removed"
969 );
970
971 // Verify the guest also reflects the removal.
972 let guest_linked_after_removal = project_b.read_with(cx_b, |project, cx| {
973 let repos = project.repositories(cx);
974 let repo = repos.values().next().unwrap();
975 repo.read(cx).linked_worktrees().to_vec()
976 });
977 assert_eq!(
978 guest_linked_after_removal, host_linked_after_removal,
979 "guest's linked_worktrees should match host's after removal"
980 );
981
982 // Test DB roundtrip: client C joins late, getting state from the database.
983 // This verifies that linked_worktrees are persisted and restored correctly.
984 let project_c = client_c.join_remote_project(project_id, cx_c).await;
985 executor.run_until_parked();
986
987 let late_joiner_linked = project_c.read_with(cx_c, |project, cx| {
988 let repos = project.repositories(cx);
989 assert_eq!(
990 repos.len(),
991 1,
992 "late joiner should have exactly 1 repository"
993 );
994 let repo = repos.values().next().unwrap();
995 repo.read(cx).linked_worktrees().to_vec()
996 });
997 assert_eq!(
998 late_joiner_linked, host_linked_after_removal,
999 "late-joining client's linked_worktrees should match host's (DB roundtrip)"
1000 );
1001
1002 // Test reconnection: disconnect client B (guest) and reconnect.
1003 // After rejoining, client B should get linked_worktrees back from the DB.
1004 server.disconnect_client(client_b.peer_id().unwrap());
1005 executor.advance_clock(RECEIVE_TIMEOUT);
1006 executor.run_until_parked();
1007
1008 // Client B reconnects automatically.
1009 executor.advance_clock(RECEIVE_TIMEOUT);
1010 executor.run_until_parked();
1011
1012 // Verify client B still has the correct linked worktrees after reconnection.
1013 let guest_linked_after_reconnect = project_b.read_with(cx_b, |project, cx| {
1014 let repos = project.repositories(cx);
1015 assert_eq!(
1016 repos.len(),
1017 1,
1018 "guest should still have exactly 1 repository after reconnect"
1019 );
1020 let repo = repos.values().next().unwrap();
1021 repo.read(cx).linked_worktrees().to_vec()
1022 });
1023 assert_eq!(
1024 guest_linked_after_reconnect, host_linked_after_removal,
1025 "guest's linked_worktrees should survive guest disconnect/reconnect"
1026 );
1027}
1028
1029#[gpui::test]
1030async fn test_diff_stat_sync_between_host_and_downstream_client(
1031 cx_a: &mut TestAppContext,
1032 cx_b: &mut TestAppContext,
1033 cx_c: &mut TestAppContext,
1034) {
1035 let mut server = TestServer::start(cx_a.background_executor.clone()).await;
1036 let client_a = server.create_client(cx_a, "user_a").await;
1037 let client_b = server.create_client(cx_b, "user_b").await;
1038 let client_c = server.create_client(cx_c, "user_c").await;
1039
1040 server
1041 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
1042 .await;
1043
1044 let fs = client_a.fs();
1045 fs.insert_tree(
1046 path!("/code"),
1047 json!({
1048 "project1": {
1049 ".git": {},
1050 "src": {
1051 "lib.rs": "line1\nline2\nline3\n",
1052 "new_file.rs": "added1\nadded2\n",
1053 },
1054 "README.md": "# project 1",
1055 }
1056 }),
1057 )
1058 .await;
1059
1060 let dot_git = Path::new(path!("/code/project1/.git"));
1061 fs.set_head_for_repo(
1062 dot_git,
1063 &[
1064 ("src/lib.rs", "line1\nold_line2\n".into()),
1065 ("src/deleted.rs", "was_here\n".into()),
1066 ],
1067 "deadbeef",
1068 );
1069 fs.set_index_for_repo(
1070 dot_git,
1071 &[
1072 ("src/lib.rs", "line1\nold_line2\nline3\nline4\n".into()),
1073 ("src/staged_only.rs", "x\ny\n".into()),
1074 ("src/new_file.rs", "added1\nadded2\n".into()),
1075 ("README.md", "# project 1".into()),
1076 ],
1077 );
1078
1079 let (project_a, worktree_id) = client_a
1080 .build_local_project(path!("/code/project1"), cx_a)
1081 .await;
1082 let active_call_a = cx_a.read(ActiveCall::global);
1083 let project_id = active_call_a
1084 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
1085 .await
1086 .unwrap();
1087 let project_b = client_b.join_remote_project(project_id, cx_b).await;
1088 let _project_c = client_c.join_remote_project(project_id, cx_c).await;
1089 cx_a.run_until_parked();
1090
1091 let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
1092 let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
1093
1094 let panel_a = workspace_a.update_in(cx_a, GitPanel::new_test);
1095 workspace_a.update_in(cx_a, |workspace, window, cx| {
1096 workspace.add_panel(panel_a.clone(), window, cx);
1097 });
1098
1099 let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test);
1100 workspace_b.update_in(cx_b, |workspace, window, cx| {
1101 workspace.add_panel(panel_b.clone(), window, cx);
1102 });
1103
1104 cx_a.run_until_parked();
1105
1106 let stats_a = collect_diff_stats(&panel_a, cx_a);
1107 let stats_b = collect_diff_stats(&panel_b, cx_b);
1108
1109 let mut expected: HashMap<RepoPath, DiffStat> = HashMap::default();
1110 expected.insert(
1111 RepoPath::new("src/lib.rs").unwrap(),
1112 DiffStat {
1113 added: 3,
1114 deleted: 2,
1115 },
1116 );
1117 expected.insert(
1118 RepoPath::new("src/deleted.rs").unwrap(),
1119 DiffStat {
1120 added: 0,
1121 deleted: 1,
1122 },
1123 );
1124 expected.insert(
1125 RepoPath::new("src/new_file.rs").unwrap(),
1126 DiffStat {
1127 added: 2,
1128 deleted: 0,
1129 },
1130 );
1131 expected.insert(
1132 RepoPath::new("README.md").unwrap(),
1133 DiffStat {
1134 added: 1,
1135 deleted: 0,
1136 },
1137 );
1138 assert_eq!(stats_a, expected, "host diff stats should match expected");
1139 assert_eq!(stats_a, stats_b, "host and remote should agree");
1140
1141 let buffer_a = project_a
1142 .update(cx_a, |p, cx| {
1143 p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx)
1144 })
1145 .await
1146 .unwrap();
1147
1148 let _buffer_b = project_b
1149 .update(cx_b, |p, cx| {
1150 p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx)
1151 })
1152 .await
1153 .unwrap();
1154 cx_a.run_until_parked();
1155
1156 buffer_a.update(cx_a, |buf, cx| {
1157 buf.edit([(buf.len()..buf.len(), "line4\n")], None, cx);
1158 });
1159 project_a
1160 .update(cx_a, |project, cx| {
1161 project.save_buffer(buffer_a.clone(), cx)
1162 })
1163 .await
1164 .unwrap();
1165 cx_a.run_until_parked();
1166
1167 let stats_a = collect_diff_stats(&panel_a, cx_a);
1168 let stats_b = collect_diff_stats(&panel_b, cx_b);
1169
1170 let mut expected_after_edit = expected.clone();
1171 expected_after_edit.insert(
1172 RepoPath::new("src/lib.rs").unwrap(),
1173 DiffStat {
1174 added: 4,
1175 deleted: 2,
1176 },
1177 );
1178 assert_eq!(
1179 stats_a, expected_after_edit,
1180 "host diff stats should reflect the edit"
1181 );
1182 assert_eq!(
1183 stats_b, expected_after_edit,
1184 "remote diff stats should reflect the host's edit"
1185 );
1186
1187 let active_call_b = cx_b.read(ActiveCall::global);
1188 active_call_b
1189 .update(cx_b, |call, cx| call.hang_up(cx))
1190 .await
1191 .unwrap();
1192 cx_a.run_until_parked();
1193
1194 let user_id_b = client_b.current_user_id(cx_b).to_proto();
1195 active_call_a
1196 .update(cx_a, |call, cx| call.invite(user_id_b, None, cx))
1197 .await
1198 .unwrap();
1199 cx_b.run_until_parked();
1200 let active_call_b = cx_b.read(ActiveCall::global);
1201 active_call_b
1202 .update(cx_b, |call, cx| call.accept_incoming(cx))
1203 .await
1204 .unwrap();
1205 cx_a.run_until_parked();
1206
1207 let project_b = client_b.join_remote_project(project_id, cx_b).await;
1208 cx_a.run_until_parked();
1209
1210 let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
1211 let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test);
1212 workspace_b.update_in(cx_b, |workspace, window, cx| {
1213 workspace.add_panel(panel_b.clone(), window, cx);
1214 });
1215 cx_b.run_until_parked();
1216
1217 let stats_b = collect_diff_stats(&panel_b, cx_b);
1218 assert_eq!(
1219 stats_b, expected_after_edit,
1220 "remote diff stats should be restored from the database after rejoining the call"
1221 );
1222}