git_tests.rs

  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}