Files
zed/crates/collab/tests/integration/git_tests.rs
T
Anthony Eid d18e4a75bc git: Add SSH support for removing and renaming git worktrees (#50759)
This should be the last step in implementing full git worktree support
in the `GitStore`. We still need to add UI for that allows a user to
rename a git worktree and, by extension git branches if we use the git
picker to do so.

Also, I added a helper function called `disallow_guest_request::<T>` to
the `Collab::rpc` that is used to specify a proto request isn't allowed
to be sent by a guest. This enabled me to add a regression test that
checks that a guest isn't allowed to delete a git worktree, without the
test hanging forever because it's waiting for the proto server to
respond.

Since SSH connections send the proto message directly from client to
remote host, this won't affect those requests.

Before you mark this PR as ready for review, make sure that you have:
- [x] Added solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [ ] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- git: Add SSH support for removing git worktrees
2026-03-10 14:57:06 +00:00

562 lines
17 KiB
Rust

use std::path::{Path, PathBuf};
use call::ActiveCall;
use collections::HashMap;
use git::{
repository::RepoPath,
status::{DiffStat, FileStatus, StatusCode, TrackedStatus},
};
use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff};
use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, VisualTestContext};
use project::ProjectPath;
use serde_json::json;
use util::{path, rel_path::rel_path};
use workspace::{MultiWorkspace, Workspace};
use crate::TestServer;
fn collect_diff_stats<C: gpui::AppContext>(
panel: &gpui::Entity<GitPanel>,
cx: &C,
) -> HashMap<RepoPath, DiffStat> {
panel.read_with(cx, |panel, cx| {
let Some(repo) = panel.active_repository() else {
return HashMap::default();
};
let snapshot = repo.read(cx).snapshot();
let mut stats = HashMap::default();
for entry in snapshot.statuses_by_path.iter() {
if let Some(diff_stat) = entry.diff_stat {
stats.insert(entry.repo_path.clone(), diff_stat);
}
}
stats
})
}
#[gpui::test]
async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.background_executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
cx_a.set_name("cx_a");
cx_b.set_name("cx_b");
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
".git": {},
"changed.txt": "after\n",
"unchanged.txt": "unchanged\n",
"created.txt": "created\n",
"secret.pem": "secret-changed\n",
}),
)
.await;
client_a.fs().set_head_and_index_for_repo(
Path::new(path!("/a/.git")),
&[
("changed.txt", "before\n".to_string()),
("unchanged.txt", "unchanged\n".to_string()),
("deleted.txt", "deleted\n".to_string()),
("secret.pem", "shh\n".to_string()),
],
);
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
let active_call_a = cx_a.read(ActiveCall::global);
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
cx_b.update(editor::init);
cx_b.update(git_ui::init);
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let window_b = cx_b.add_window(|window, cx| {
let workspace = cx.new(|cx| {
Workspace::new(
None,
project_b.clone(),
client_b.app_state.clone(),
window,
cx,
)
});
MultiWorkspace::new(workspace, window, cx)
});
let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b);
let workspace_b = window_b
.root(cx_b)
.unwrap()
.read_with(cx_b, |multi_workspace, _| {
multi_workspace.workspace().clone()
});
cx_b.update(|window, cx| {
window
.focused(cx)
.unwrap()
.dispatch_action(&git_ui::project_diff::Diff, window, cx)
});
let diff = workspace_b.update(cx_b, |workspace, cx| {
workspace.active_item(cx).unwrap().act_as::<ProjectDiff>(cx)
});
let diff = diff.unwrap();
cx_b.run_until_parked();
diff.update(cx_b, |diff, cx| {
assert_eq!(
diff.excerpt_paths(cx),
vec![
rel_path("changed.txt").into_arc(),
rel_path("deleted.txt").into_arc(),
rel_path("created.txt").into_arc()
]
);
});
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
".git": {},
"changed.txt": "before\n",
"unchanged.txt": "changed\n",
"created.txt": "created\n",
"secret.pem": "secret-changed\n",
}),
)
.await;
cx_b.run_until_parked();
project_b.update(cx_b, |project, cx| {
let project_path = ProjectPath {
worktree_id,
path: rel_path("unchanged.txt").into(),
};
let status = project.project_path_git_status(&project_path, cx);
assert_eq!(
status.unwrap(),
FileStatus::Tracked(TrackedStatus {
worktree_status: StatusCode::Modified,
index_status: StatusCode::Unmodified,
})
);
});
diff.update(cx_b, |diff, cx| {
assert_eq!(
diff.excerpt_paths(cx),
vec![
rel_path("deleted.txt").into_arc(),
rel_path("unchanged.txt").into_arc(),
rel_path("created.txt").into_arc()
]
);
});
}
#[gpui::test]
async fn test_remote_git_worktrees(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs()
.insert_tree(
path!("/project"),
json!({ ".git": {}, "file.txt": "content" }),
)
.await;
let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
executor.run_until_parked();
let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
// Initially only the main worktree (the repo itself) should be present
let worktrees = cx_b
.update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
.await
.unwrap()
.unwrap();
assert_eq!(worktrees.len(), 1);
assert_eq!(worktrees[0].path, PathBuf::from(path!("/project")));
// Client B creates a git worktree via the remote project
let worktree_directory = PathBuf::from(path!("/project"));
cx_b.update(|cx| {
repo_b.update(cx, |repository, _| {
repository.create_worktree(
"feature-branch".to_string(),
worktree_directory.clone(),
Some("abc123".to_string()),
)
})
})
.await
.unwrap()
.unwrap();
executor.run_until_parked();
// Client B lists worktrees — should see main + the one just created
let worktrees = cx_b
.update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
.await
.unwrap()
.unwrap();
assert_eq!(worktrees.len(), 2);
assert_eq!(worktrees[0].path, PathBuf::from(path!("/project")));
assert_eq!(worktrees[1].path, worktree_directory.join("feature-branch"));
assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch");
assert_eq!(worktrees[1].sha.as_ref(), "abc123");
// Verify from the host side that the worktree was actually created
let host_worktrees = {
let repo_a = cx_a.update(|cx| {
project_a
.read(cx)
.repositories(cx)
.values()
.next()
.unwrap()
.clone()
});
cx_a.update(|cx| repo_a.update(cx, |repository, _| repository.worktrees()))
.await
.unwrap()
.unwrap()
};
assert_eq!(host_worktrees.len(), 2);
assert_eq!(host_worktrees[0].path, PathBuf::from(path!("/project")));
assert_eq!(
host_worktrees[1].path,
worktree_directory.join("feature-branch")
);
// Client B creates a second git worktree without an explicit commit
cx_b.update(|cx| {
repo_b.update(cx, |repository, _| {
repository.create_worktree(
"bugfix-branch".to_string(),
worktree_directory.clone(),
None,
)
})
})
.await
.unwrap()
.unwrap();
executor.run_until_parked();
// Client B lists worktrees — should now have main + two created
let worktrees = cx_b
.update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
.await
.unwrap()
.unwrap();
assert_eq!(worktrees.len(), 3);
let feature_worktree = worktrees
.iter()
.find(|worktree| worktree.ref_name.as_ref() == "refs/heads/feature-branch")
.expect("should find feature-branch worktree");
assert_eq!(
feature_worktree.path,
worktree_directory.join("feature-branch")
);
let bugfix_worktree = worktrees
.iter()
.find(|worktree| worktree.ref_name.as_ref() == "refs/heads/bugfix-branch")
.expect("should find bugfix-branch worktree");
assert_eq!(
bugfix_worktree.path,
worktree_directory.join("bugfix-branch")
);
assert_eq!(bugfix_worktree.sha.as_ref(), "fake-sha");
// Client B (guest) attempts to rename a worktree. This should fail
// because worktree renaming is not forwarded through collab
let rename_result = cx_b
.update(|cx| {
repo_b.update(cx, |repository, _| {
repository.rename_worktree(
worktree_directory.join("feature-branch"),
worktree_directory.join("renamed-branch"),
)
})
})
.await
.unwrap();
assert!(
rename_result.is_err(),
"Guest should not be able to rename worktrees via collab"
);
executor.run_until_parked();
// Verify worktrees are unchanged — still 3
let worktrees = cx_b
.update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
.await
.unwrap()
.unwrap();
assert_eq!(
worktrees.len(),
3,
"Worktree count should be unchanged after failed rename"
);
// Client B (guest) attempts to remove a worktree. This should fail
// because worktree removal is not forwarded through collab
let remove_result = cx_b
.update(|cx| {
repo_b.update(cx, |repository, _| {
repository.remove_worktree(worktree_directory.join("feature-branch"), false)
})
})
.await
.unwrap();
assert!(
remove_result.is_err(),
"Guest should not be able to remove worktrees via collab"
);
executor.run_until_parked();
// Verify worktrees are unchanged — still 3
let worktrees = cx_b
.update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
.await
.unwrap()
.unwrap();
assert_eq!(
worktrees.len(),
3,
"Worktree count should be unchanged after failed removal"
);
}
#[gpui::test]
async fn test_diff_stat_sync_between_host_and_downstream_client(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.background_executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
.await;
let fs = client_a.fs();
fs.insert_tree(
path!("/code"),
json!({
"project1": {
".git": {},
"src": {
"lib.rs": "line1\nline2\nline3\n",
"new_file.rs": "added1\nadded2\n",
},
"README.md": "# project 1",
}
}),
)
.await;
let dot_git = Path::new(path!("/code/project1/.git"));
fs.set_head_for_repo(
dot_git,
&[
("src/lib.rs", "line1\nold_line2\n".into()),
("src/deleted.rs", "was_here\n".into()),
],
"deadbeef",
);
fs.set_index_for_repo(
dot_git,
&[
("src/lib.rs", "line1\nold_line2\nline3\nline4\n".into()),
("src/staged_only.rs", "x\ny\n".into()),
("src/new_file.rs", "added1\nadded2\n".into()),
("README.md", "# project 1".into()),
],
);
let (project_a, worktree_id) = client_a
.build_local_project(path!("/code/project1"), cx_a)
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let _project_c = client_c.join_remote_project(project_id, cx_c).await;
cx_a.run_until_parked();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let panel_a = workspace_a.update_in(cx_a, GitPanel::new_test);
workspace_a.update_in(cx_a, |workspace, window, cx| {
workspace.add_panel(panel_a.clone(), window, cx);
});
let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test);
workspace_b.update_in(cx_b, |workspace, window, cx| {
workspace.add_panel(panel_b.clone(), window, cx);
});
cx_a.run_until_parked();
let stats_a = collect_diff_stats(&panel_a, cx_a);
let stats_b = collect_diff_stats(&panel_b, cx_b);
let mut expected: HashMap<RepoPath, DiffStat> = HashMap::default();
expected.insert(
RepoPath::new("src/lib.rs").unwrap(),
DiffStat {
added: 3,
deleted: 2,
},
);
expected.insert(
RepoPath::new("src/deleted.rs").unwrap(),
DiffStat {
added: 0,
deleted: 1,
},
);
expected.insert(
RepoPath::new("src/new_file.rs").unwrap(),
DiffStat {
added: 2,
deleted: 0,
},
);
expected.insert(
RepoPath::new("README.md").unwrap(),
DiffStat {
added: 1,
deleted: 0,
},
);
assert_eq!(stats_a, expected, "host diff stats should match expected");
assert_eq!(stats_a, stats_b, "host and remote should agree");
let buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx)
})
.await
.unwrap();
let _buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx)
})
.await
.unwrap();
cx_a.run_until_parked();
buffer_a.update(cx_a, |buf, cx| {
buf.edit([(buf.len()..buf.len(), "line4\n")], None, cx);
});
project_a
.update(cx_a, |project, cx| {
project.save_buffer(buffer_a.clone(), cx)
})
.await
.unwrap();
cx_a.run_until_parked();
let stats_a = collect_diff_stats(&panel_a, cx_a);
let stats_b = collect_diff_stats(&panel_b, cx_b);
let mut expected_after_edit = expected.clone();
expected_after_edit.insert(
RepoPath::new("src/lib.rs").unwrap(),
DiffStat {
added: 4,
deleted: 2,
},
);
assert_eq!(
stats_a, expected_after_edit,
"host diff stats should reflect the edit"
);
assert_eq!(
stats_b, expected_after_edit,
"remote diff stats should reflect the host's edit"
);
let active_call_b = cx_b.read(ActiveCall::global);
active_call_b
.update(cx_b, |call, cx| call.hang_up(cx))
.await
.unwrap();
cx_a.run_until_parked();
let user_id_b = client_b.current_user_id(cx_b).to_proto();
active_call_a
.update(cx_a, |call, cx| call.invite(user_id_b, None, cx))
.await
.unwrap();
cx_b.run_until_parked();
let active_call_b = cx_b.read(ActiveCall::global);
active_call_b
.update(cx_b, |call, cx| call.accept_incoming(cx))
.await
.unwrap();
cx_a.run_until_parked();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
cx_a.run_until_parked();
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test);
workspace_b.update_in(cx_b, |workspace, window, cx| {
workspace.add_panel(panel_b.clone(), window, cx);
});
cx_b.run_until_parked();
let stats_b = collect_diff_stats(&panel_b, cx_b);
assert_eq!(
stats_b, expected_after_edit,
"remote diff stats should be restored from the database after rejoining the call"
);
}