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