1use std::path::{Path, PathBuf};
2
3use call::ActiveCall;
4use collections::HashMap;
5use git::{
6 repository::RepoPath,
7 status::{DiffStat, FileStatus, StatusCode, TrackedStatus},
8};
9use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff};
10use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, VisualTestContext};
11use project::ProjectPath;
12use serde_json::json;
13
14use util::{path, rel_path::rel_path};
15use workspace::{MultiWorkspace, Workspace};
16
17use crate::TestServer;
18
19fn collect_diff_stats<C: gpui::AppContext>(
20 panel: &gpui::Entity<GitPanel>,
21 cx: &C,
22) -> HashMap<RepoPath, DiffStat> {
23 panel.read_with(cx, |panel, cx| {
24 let Some(repo) = panel.active_repository() else {
25 return HashMap::default();
26 };
27 let snapshot = repo.read(cx).snapshot();
28 let mut stats = HashMap::default();
29 for entry in snapshot.statuses_by_path.iter() {
30 if let Some(diff_stat) = entry.diff_stat {
31 stats.insert(entry.repo_path.clone(), diff_stat);
32 }
33 }
34 stats
35 })
36}
37
38#[gpui::test]
39async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
40 let mut server = TestServer::start(cx_a.background_executor.clone()).await;
41 let client_a = server.create_client(cx_a, "user_a").await;
42 let client_b = server.create_client(cx_b, "user_b").await;
43 cx_a.set_name("cx_a");
44 cx_b.set_name("cx_b");
45
46 server
47 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
48 .await;
49
50 client_a
51 .fs()
52 .insert_tree(
53 path!("/a"),
54 json!({
55 ".git": {},
56 "changed.txt": "after\n",
57 "unchanged.txt": "unchanged\n",
58 "created.txt": "created\n",
59 "secret.pem": "secret-changed\n",
60 }),
61 )
62 .await;
63
64 client_a.fs().set_head_and_index_for_repo(
65 Path::new(path!("/a/.git")),
66 &[
67 ("changed.txt", "before\n".to_string()),
68 ("unchanged.txt", "unchanged\n".to_string()),
69 ("deleted.txt", "deleted\n".to_string()),
70 ("secret.pem", "shh\n".to_string()),
71 ],
72 );
73 let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
74 let active_call_a = cx_a.read(ActiveCall::global);
75 let project_id = active_call_a
76 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
77 .await
78 .unwrap();
79
80 cx_b.update(editor::init);
81 cx_b.update(git_ui::init);
82 let project_b = client_b.join_remote_project(project_id, cx_b).await;
83 let window_b = cx_b.add_window(|window, cx| {
84 let workspace = cx.new(|cx| {
85 Workspace::new(
86 None,
87 project_b.clone(),
88 client_b.app_state.clone(),
89 window,
90 cx,
91 )
92 });
93 MultiWorkspace::new(workspace, window, cx)
94 });
95 let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b);
96 let workspace_b = window_b
97 .root(cx_b)
98 .unwrap()
99 .read_with(cx_b, |multi_workspace, _| {
100 multi_workspace.workspace().clone()
101 });
102
103 cx_b.update(|window, cx| {
104 window
105 .focused(cx)
106 .unwrap()
107 .dispatch_action(&git_ui::project_diff::Diff, window, cx)
108 });
109 let diff = workspace_b.update(cx_b, |workspace, cx| {
110 workspace.active_item(cx).unwrap().act_as::<ProjectDiff>(cx)
111 });
112 let diff = diff.unwrap();
113 cx_b.run_until_parked();
114
115 diff.update(cx_b, |diff, cx| {
116 assert_eq!(
117 diff.excerpt_paths(cx),
118 vec![
119 rel_path("changed.txt").into_arc(),
120 rel_path("deleted.txt").into_arc(),
121 rel_path("created.txt").into_arc()
122 ]
123 );
124 });
125
126 client_a
127 .fs()
128 .insert_tree(
129 path!("/a"),
130 json!({
131 ".git": {},
132 "changed.txt": "before\n",
133 "unchanged.txt": "changed\n",
134 "created.txt": "created\n",
135 "secret.pem": "secret-changed\n",
136 }),
137 )
138 .await;
139 cx_b.run_until_parked();
140
141 project_b.update(cx_b, |project, cx| {
142 let project_path = ProjectPath {
143 worktree_id,
144 path: rel_path("unchanged.txt").into(),
145 };
146 let status = project.project_path_git_status(&project_path, cx);
147 assert_eq!(
148 status.unwrap(),
149 FileStatus::Tracked(TrackedStatus {
150 worktree_status: StatusCode::Modified,
151 index_status: StatusCode::Unmodified,
152 })
153 );
154 });
155
156 diff.update(cx_b, |diff, cx| {
157 assert_eq!(
158 diff.excerpt_paths(cx),
159 vec![
160 rel_path("deleted.txt").into_arc(),
161 rel_path("unchanged.txt").into_arc(),
162 rel_path("created.txt").into_arc()
163 ]
164 );
165 });
166}
167
168#[gpui::test]
169async fn test_remote_git_worktrees(
170 executor: BackgroundExecutor,
171 cx_a: &mut TestAppContext,
172 cx_b: &mut TestAppContext,
173) {
174 let mut server = TestServer::start(executor.clone()).await;
175 let client_a = server.create_client(cx_a, "user_a").await;
176 let client_b = server.create_client(cx_b, "user_b").await;
177 server
178 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
179 .await;
180 let active_call_a = cx_a.read(ActiveCall::global);
181
182 client_a
183 .fs()
184 .insert_tree(
185 path!("/project"),
186 json!({ ".git": {}, "file.txt": "content" }),
187 )
188 .await;
189
190 let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
191
192 let project_id = active_call_a
193 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
194 .await
195 .unwrap();
196 let project_b = client_b.join_remote_project(project_id, cx_b).await;
197
198 executor.run_until_parked();
199
200 let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
201
202 // Initially only the main worktree (the repo itself) should be present
203 let worktrees = cx_b
204 .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
205 .await
206 .unwrap()
207 .unwrap();
208 assert_eq!(worktrees.len(), 1);
209 assert_eq!(worktrees[0].path, PathBuf::from(path!("/project")));
210
211 // Client B creates a git worktree via the remote project
212 let worktree_directory = PathBuf::from(path!("/project"));
213 cx_b.update(|cx| {
214 repo_b.update(cx, |repository, _| {
215 repository.create_worktree(
216 "feature-branch".to_string(),
217 worktree_directory.clone(),
218 Some("abc123".to_string()),
219 )
220 })
221 })
222 .await
223 .unwrap()
224 .unwrap();
225
226 executor.run_until_parked();
227
228 // Client B lists worktrees — should see main + the one just created
229 let worktrees = cx_b
230 .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
231 .await
232 .unwrap()
233 .unwrap();
234 assert_eq!(worktrees.len(), 2);
235 assert_eq!(worktrees[0].path, PathBuf::from(path!("/project")));
236 assert_eq!(worktrees[1].path, worktree_directory.join("feature-branch"));
237 assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch");
238 assert_eq!(worktrees[1].sha.as_ref(), "abc123");
239
240 // Verify from the host side that the worktree was actually created
241 let host_worktrees = {
242 let repo_a = cx_a.update(|cx| {
243 project_a
244 .read(cx)
245 .repositories(cx)
246 .values()
247 .next()
248 .unwrap()
249 .clone()
250 });
251 cx_a.update(|cx| repo_a.update(cx, |repository, _| repository.worktrees()))
252 .await
253 .unwrap()
254 .unwrap()
255 };
256 assert_eq!(host_worktrees.len(), 2);
257 assert_eq!(host_worktrees[0].path, PathBuf::from(path!("/project")));
258 assert_eq!(
259 host_worktrees[1].path,
260 worktree_directory.join("feature-branch")
261 );
262
263 // Client B creates a second git worktree without an explicit commit
264 cx_b.update(|cx| {
265 repo_b.update(cx, |repository, _| {
266 repository.create_worktree(
267 "bugfix-branch".to_string(),
268 worktree_directory.clone(),
269 None,
270 )
271 })
272 })
273 .await
274 .unwrap()
275 .unwrap();
276
277 executor.run_until_parked();
278
279 // Client B lists worktrees — should now have main + two created
280 let worktrees = cx_b
281 .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
282 .await
283 .unwrap()
284 .unwrap();
285 assert_eq!(worktrees.len(), 3);
286
287 let feature_worktree = worktrees
288 .iter()
289 .find(|worktree| worktree.ref_name.as_ref() == "refs/heads/feature-branch")
290 .expect("should find feature-branch worktree");
291 assert_eq!(
292 feature_worktree.path,
293 worktree_directory.join("feature-branch")
294 );
295
296 let bugfix_worktree = worktrees
297 .iter()
298 .find(|worktree| worktree.ref_name.as_ref() == "refs/heads/bugfix-branch")
299 .expect("should find bugfix-branch worktree");
300 assert_eq!(
301 bugfix_worktree.path,
302 worktree_directory.join("bugfix-branch")
303 );
304 assert_eq!(bugfix_worktree.sha.as_ref(), "fake-sha");
305}
306
307#[gpui::test]
308async fn test_diff_stat_sync_between_host_and_downstream_client(
309 cx_a: &mut TestAppContext,
310 cx_b: &mut TestAppContext,
311 cx_c: &mut TestAppContext,
312) {
313 let mut server = TestServer::start(cx_a.background_executor.clone()).await;
314 let client_a = server.create_client(cx_a, "user_a").await;
315 let client_b = server.create_client(cx_b, "user_b").await;
316 let client_c = server.create_client(cx_c, "user_c").await;
317
318 server
319 .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
320 .await;
321
322 let fs = client_a.fs();
323 fs.insert_tree(
324 path!("/code"),
325 json!({
326 "project1": {
327 ".git": {},
328 "src": {
329 "lib.rs": "line1\nline2\nline3\n",
330 "new_file.rs": "added1\nadded2\n",
331 },
332 "README.md": "# project 1",
333 }
334 }),
335 )
336 .await;
337
338 let dot_git = Path::new(path!("/code/project1/.git"));
339 fs.set_head_for_repo(
340 dot_git,
341 &[
342 ("src/lib.rs", "line1\nold_line2\n".into()),
343 ("src/deleted.rs", "was_here\n".into()),
344 ],
345 "deadbeef",
346 );
347 fs.set_index_for_repo(
348 dot_git,
349 &[
350 ("src/lib.rs", "line1\nold_line2\nline3\nline4\n".into()),
351 ("src/staged_only.rs", "x\ny\n".into()),
352 ("src/new_file.rs", "added1\nadded2\n".into()),
353 ("README.md", "# project 1".into()),
354 ],
355 );
356
357 let (project_a, worktree_id) = client_a
358 .build_local_project(path!("/code/project1"), cx_a)
359 .await;
360 let active_call_a = cx_a.read(ActiveCall::global);
361 let project_id = active_call_a
362 .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
363 .await
364 .unwrap();
365 let project_b = client_b.join_remote_project(project_id, cx_b).await;
366 let _project_c = client_c.join_remote_project(project_id, cx_c).await;
367 cx_a.run_until_parked();
368
369 let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
370 let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
371
372 let panel_a = workspace_a.update_in(cx_a, GitPanel::new_test);
373 workspace_a.update_in(cx_a, |workspace, window, cx| {
374 workspace.add_panel(panel_a.clone(), window, cx);
375 });
376
377 let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test);
378 workspace_b.update_in(cx_b, |workspace, window, cx| {
379 workspace.add_panel(panel_b.clone(), window, cx);
380 });
381
382 cx_a.run_until_parked();
383
384 let stats_a = collect_diff_stats(&panel_a, cx_a);
385 let stats_b = collect_diff_stats(&panel_b, cx_b);
386
387 let mut expected: HashMap<RepoPath, DiffStat> = HashMap::default();
388 expected.insert(
389 RepoPath::new("src/lib.rs").unwrap(),
390 DiffStat {
391 added: 3,
392 deleted: 2,
393 },
394 );
395 expected.insert(
396 RepoPath::new("src/deleted.rs").unwrap(),
397 DiffStat {
398 added: 0,
399 deleted: 1,
400 },
401 );
402 expected.insert(
403 RepoPath::new("src/new_file.rs").unwrap(),
404 DiffStat {
405 added: 2,
406 deleted: 0,
407 },
408 );
409 expected.insert(
410 RepoPath::new("README.md").unwrap(),
411 DiffStat {
412 added: 1,
413 deleted: 0,
414 },
415 );
416 assert_eq!(stats_a, expected, "host diff stats should match expected");
417 assert_eq!(stats_a, stats_b, "host and remote should agree");
418
419 let buffer_a = project_a
420 .update(cx_a, |p, cx| {
421 p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx)
422 })
423 .await
424 .unwrap();
425
426 let _buffer_b = project_b
427 .update(cx_b, |p, cx| {
428 p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx)
429 })
430 .await
431 .unwrap();
432 cx_a.run_until_parked();
433
434 buffer_a.update(cx_a, |buf, cx| {
435 buf.edit([(buf.len()..buf.len(), "line4\n")], None, cx);
436 });
437 project_a
438 .update(cx_a, |project, cx| {
439 project.save_buffer(buffer_a.clone(), cx)
440 })
441 .await
442 .unwrap();
443 cx_a.run_until_parked();
444
445 let stats_a = collect_diff_stats(&panel_a, cx_a);
446 let stats_b = collect_diff_stats(&panel_b, cx_b);
447
448 let mut expected_after_edit = expected.clone();
449 expected_after_edit.insert(
450 RepoPath::new("src/lib.rs").unwrap(),
451 DiffStat {
452 added: 4,
453 deleted: 2,
454 },
455 );
456 assert_eq!(
457 stats_a, expected_after_edit,
458 "host diff stats should reflect the edit"
459 );
460 assert_eq!(
461 stats_b, expected_after_edit,
462 "remote diff stats should reflect the host's edit"
463 );
464
465 let active_call_b = cx_b.read(ActiveCall::global);
466 active_call_b
467 .update(cx_b, |call, cx| call.hang_up(cx))
468 .await
469 .unwrap();
470 cx_a.run_until_parked();
471
472 let user_id_b = client_b.current_user_id(cx_b).to_proto();
473 active_call_a
474 .update(cx_a, |call, cx| call.invite(user_id_b, None, cx))
475 .await
476 .unwrap();
477 cx_b.run_until_parked();
478 let active_call_b = cx_b.read(ActiveCall::global);
479 active_call_b
480 .update(cx_b, |call, cx| call.accept_incoming(cx))
481 .await
482 .unwrap();
483 cx_a.run_until_parked();
484
485 let project_b = client_b.join_remote_project(project_id, cx_b).await;
486 cx_a.run_until_parked();
487
488 let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
489 let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test);
490 workspace_b.update_in(cx_b, |workspace, window, cx| {
491 workspace.add_panel(panel_b.clone(), window, cx);
492 });
493 cx_b.run_until_parked();
494
495 let stats_b = collect_diff_stats(&panel_b, cx_b);
496 assert_eq!(
497 stats_b, expected_after_edit,
498 "remote diff stats should be restored from the database after rejoining the call"
499 );
500}