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::{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    // Client B (guest) attempts to rename a worktree. This should fail
307    // because worktree renaming is not forwarded through collab
308    let rename_result = cx_b
309        .update(|cx| {
310            repo_b.update(cx, |repository, _| {
311                repository.rename_worktree(
312                    worktree_directory.join("feature-branch"),
313                    worktree_directory.join("renamed-branch"),
314                )
315            })
316        })
317        .await
318        .unwrap();
319    assert!(
320        rename_result.is_err(),
321        "Guest should not be able to rename worktrees via collab"
322    );
323
324    executor.run_until_parked();
325
326    // Verify worktrees are unchanged — still 3
327    let worktrees = cx_b
328        .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
329        .await
330        .unwrap()
331        .unwrap();
332    assert_eq!(
333        worktrees.len(),
334        3,
335        "Worktree count should be unchanged after failed rename"
336    );
337
338    // Client B (guest) attempts to remove a worktree. This should fail
339    // because worktree removal is not forwarded through collab
340    let remove_result = cx_b
341        .update(|cx| {
342            repo_b.update(cx, |repository, _| {
343                repository.remove_worktree(worktree_directory.join("feature-branch"), false)
344            })
345        })
346        .await
347        .unwrap();
348    assert!(
349        remove_result.is_err(),
350        "Guest should not be able to remove worktrees via collab"
351    );
352
353    executor.run_until_parked();
354
355    // Verify worktrees are unchanged — still 3
356    let worktrees = cx_b
357        .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
358        .await
359        .unwrap()
360        .unwrap();
361    assert_eq!(
362        worktrees.len(),
363        3,
364        "Worktree count should be unchanged after failed removal"
365    );
366}
367
368#[gpui::test]
369async fn test_diff_stat_sync_between_host_and_downstream_client(
370    cx_a: &mut TestAppContext,
371    cx_b: &mut TestAppContext,
372    cx_c: &mut TestAppContext,
373) {
374    let mut server = TestServer::start(cx_a.background_executor.clone()).await;
375    let client_a = server.create_client(cx_a, "user_a").await;
376    let client_b = server.create_client(cx_b, "user_b").await;
377    let client_c = server.create_client(cx_c, "user_c").await;
378
379    server
380        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
381        .await;
382
383    let fs = client_a.fs();
384    fs.insert_tree(
385        path!("/code"),
386        json!({
387            "project1": {
388                ".git": {},
389                "src": {
390                    "lib.rs": "line1\nline2\nline3\n",
391                    "new_file.rs": "added1\nadded2\n",
392                },
393                "README.md": "# project 1",
394            }
395        }),
396    )
397    .await;
398
399    let dot_git = Path::new(path!("/code/project1/.git"));
400    fs.set_head_for_repo(
401        dot_git,
402        &[
403            ("src/lib.rs", "line1\nold_line2\n".into()),
404            ("src/deleted.rs", "was_here\n".into()),
405        ],
406        "deadbeef",
407    );
408    fs.set_index_for_repo(
409        dot_git,
410        &[
411            ("src/lib.rs", "line1\nold_line2\nline3\nline4\n".into()),
412            ("src/staged_only.rs", "x\ny\n".into()),
413            ("src/new_file.rs", "added1\nadded2\n".into()),
414            ("README.md", "# project 1".into()),
415        ],
416    );
417
418    let (project_a, worktree_id) = client_a
419        .build_local_project(path!("/code/project1"), cx_a)
420        .await;
421    let active_call_a = cx_a.read(ActiveCall::global);
422    let project_id = active_call_a
423        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
424        .await
425        .unwrap();
426    let project_b = client_b.join_remote_project(project_id, cx_b).await;
427    let _project_c = client_c.join_remote_project(project_id, cx_c).await;
428    cx_a.run_until_parked();
429
430    let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
431    let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
432
433    let panel_a = workspace_a.update_in(cx_a, GitPanel::new_test);
434    workspace_a.update_in(cx_a, |workspace, window, cx| {
435        workspace.add_panel(panel_a.clone(), window, cx);
436    });
437
438    let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test);
439    workspace_b.update_in(cx_b, |workspace, window, cx| {
440        workspace.add_panel(panel_b.clone(), window, cx);
441    });
442
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: HashMap<RepoPath, DiffStat> = HashMap::default();
449    expected.insert(
450        RepoPath::new("src/lib.rs").unwrap(),
451        DiffStat {
452            added: 3,
453            deleted: 2,
454        },
455    );
456    expected.insert(
457        RepoPath::new("src/deleted.rs").unwrap(),
458        DiffStat {
459            added: 0,
460            deleted: 1,
461        },
462    );
463    expected.insert(
464        RepoPath::new("src/new_file.rs").unwrap(),
465        DiffStat {
466            added: 2,
467            deleted: 0,
468        },
469    );
470    expected.insert(
471        RepoPath::new("README.md").unwrap(),
472        DiffStat {
473            added: 1,
474            deleted: 0,
475        },
476    );
477    assert_eq!(stats_a, expected, "host diff stats should match expected");
478    assert_eq!(stats_a, stats_b, "host and remote should agree");
479
480    let buffer_a = project_a
481        .update(cx_a, |p, cx| {
482            p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx)
483        })
484        .await
485        .unwrap();
486
487    let _buffer_b = project_b
488        .update(cx_b, |p, cx| {
489            p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx)
490        })
491        .await
492        .unwrap();
493    cx_a.run_until_parked();
494
495    buffer_a.update(cx_a, |buf, cx| {
496        buf.edit([(buf.len()..buf.len(), "line4\n")], None, cx);
497    });
498    project_a
499        .update(cx_a, |project, cx| {
500            project.save_buffer(buffer_a.clone(), cx)
501        })
502        .await
503        .unwrap();
504    cx_a.run_until_parked();
505
506    let stats_a = collect_diff_stats(&panel_a, cx_a);
507    let stats_b = collect_diff_stats(&panel_b, cx_b);
508
509    let mut expected_after_edit = expected.clone();
510    expected_after_edit.insert(
511        RepoPath::new("src/lib.rs").unwrap(),
512        DiffStat {
513            added: 4,
514            deleted: 2,
515        },
516    );
517    assert_eq!(
518        stats_a, expected_after_edit,
519        "host diff stats should reflect the edit"
520    );
521    assert_eq!(
522        stats_b, expected_after_edit,
523        "remote diff stats should reflect the host's edit"
524    );
525
526    let active_call_b = cx_b.read(ActiveCall::global);
527    active_call_b
528        .update(cx_b, |call, cx| call.hang_up(cx))
529        .await
530        .unwrap();
531    cx_a.run_until_parked();
532
533    let user_id_b = client_b.current_user_id(cx_b).to_proto();
534    active_call_a
535        .update(cx_a, |call, cx| call.invite(user_id_b, None, cx))
536        .await
537        .unwrap();
538    cx_b.run_until_parked();
539    let active_call_b = cx_b.read(ActiveCall::global);
540    active_call_b
541        .update(cx_b, |call, cx| call.accept_incoming(cx))
542        .await
543        .unwrap();
544    cx_a.run_until_parked();
545
546    let project_b = client_b.join_remote_project(project_id, cx_b).await;
547    cx_a.run_until_parked();
548
549    let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
550    let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test);
551    workspace_b.update_in(cx_b, |workspace, window, cx| {
552        workspace.add_panel(panel_b.clone(), window, cx);
553    });
554    cx_b.run_until_parked();
555
556    let stats_b = collect_diff_stats(&panel_b, cx_b);
557    assert_eq!(
558        stats_b, expected_after_edit,
559        "remote diff stats should be restored from the database after rejoining the call"
560    );
561}