agent: Support remote archive thread git operations (#54001)

### Summary

This PR implements remote support for git store operations that the
sidebar's archive thread checkpoint/restore featured relied on. This was
the second to last blocker for remote usage of this feature.

I also made a shared backend between `update_ref` and `delete_ref`
called `edit_ref` because they run the same git command and this allowed
for some code unification.

#### Remote Git Operations 
- `Repository::update_ref`
- `Repository::delete_ref`
- `Repository::repair_worktrees`
- `Repository::create_archive_checkpoint`
- `Repository::restore_archive_checkpoint`


#### Follow up

`agent_ui::thread_worktree_archive::find_or_create_repository` needs to
be made aware of the remote machine that the repository it's searching
for is on. Once that is completed, we can get the correct repo when
archiving a remote thread and the flow should work without any problems.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Anthony Eid authored and GitHub committed 2026-04-15 19:55:37 +00:00
1 parent e2780f6fbd
commit d60b55675b
8 files changed
+348 -57

No files matched your search

+4
View File
@@ -439,6 +439,10 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::GitCreateWorktree>)
.add_request_handler(disallow_guest_request::<proto::GitRemoveWorktree>)
.add_request_handler(disallow_guest_request::<proto::GitRenameWorktree>)
.add_request_handler(forward_mutating_project_request::<proto::GitEditRef>)
.add_request_handler(forward_mutating_project_request::<proto::GitRepairWorktrees>)
.add_request_handler(disallow_guest_request::<proto::GitCreateArchiveCheckpoint>)
.add_request_handler(disallow_guest_request::<proto::GitRestoreArchiveCheckpoint>)
.add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)
.add_request_handler(forward_mutating_project_request::<proto::ToggleLspLogs>)
.add_message_handler(broadcast_project_message_from_host::<proto::LanguageServerLog>)
+18 -10
View File
@@ -10,8 +10,8 @@ use git::{
repository::{
AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions,
CreateWorktreeTarget, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository,
GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote,
RepoPath, ResetMode, SearchCommitArgs, Worktree,
GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, RefEdit,
Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree,
},
stash::GitStash,
status::{
@@ -109,6 +109,20 @@ impl FakeGitRepository {
.boxed()
}
fn edit_ref(&self, edit: RefEdit) -> BoxFuture<'_, Result<()>> {
self.with_state_async(true, move |state| {
match edit {
RefEdit::Update { ref_name, commit } => {
state.refs.insert(ref_name, commit);
}
RefEdit::Delete { ref_name } => {
state.refs.remove(&ref_name);
}
}
Ok(())
})
}
/// Scans `.git/worktrees/*/gitdir` to find the admin entry directory for a
/// worktree at the given checkout path. Used when the working tree directory
/// has already been deleted and we can't read its `.git` pointer file.
@@ -1437,17 +1451,11 @@ impl GitRepository for FakeGitRepository {
}
fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> {
self.with_state_async(true, move |state| {
state.refs.insert(ref_name, commit);
Ok(())
})
self.edit_ref(RefEdit::Update { ref_name, commit })
}
fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
self.with_state_async(true, move |state| {
state.refs.remove(&ref_name);
Ok(())
})
self.edit_ref(RefEdit::Delete { ref_name })
}
fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> {
+32 -16
View File
@@ -1043,6 +1043,25 @@ pub struct RealGitRepository {
is_trusted: Arc<AtomicBool>,
}
#[derive(Debug)]
pub enum RefEdit {
Update { ref_name: String, commit: String },
Delete { ref_name: String },
}
impl RefEdit {
fn into_args(self) -> Vec<OsString> {
match self {
Self::Update { ref_name, commit } => {
vec!["update-ref".into(), ref_name.into(), commit.into()]
}
Self::Delete { ref_name } => {
vec!["update-ref".into(), "-d".into(), ref_name.into()]
}
}
}
}
impl RealGitRepository {
pub fn new(
dotgit_path: &Path,
@@ -1089,6 +1108,17 @@ impl RealGitRepository {
))
}
fn edit_ref(&self, edit: RefEdit) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let args = edit.into_args();
git_binary?.run(&args).await?;
Ok(())
})
.boxed()
}
async fn any_git_binary_help_output(&self) -> SharedString {
if let Some(output) = self.any_git_binary_help_output.lock().clone() {
return output;
@@ -2316,25 +2346,11 @@ impl GitRepository for RealGitRepository {
}
fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let args: Vec<OsString> = vec!["update-ref".into(), ref_name.into(), commit.into()];
git_binary?.run(&args).await?;
Ok(())
})
.boxed()
self.edit_ref(RefEdit::Update { ref_name, commit })
}
fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let args: Vec<OsString> = vec!["update-ref".into(), "-d".into(), ref_name.into()];
git_binary?.run(&args).await?;
Ok(())
})
.boxed()
self.edit_ref(RefEdit::Delete { ref_name })
}
fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> {
+147 -30
View File
@@ -563,7 +563,9 @@ impl GitStore {
client.add_entity_request_handler(Self::handle_reset);
client.add_entity_request_handler(Self::handle_show);
client.add_entity_request_handler(Self::handle_create_checkpoint);
client.add_entity_request_handler(Self::handle_create_archive_checkpoint);
client.add_entity_request_handler(Self::handle_restore_checkpoint);
client.add_entity_request_handler(Self::handle_restore_archive_checkpoint);
client.add_entity_request_handler(Self::handle_compare_checkpoints);
client.add_entity_request_handler(Self::handle_diff_checkpoints);
client.add_entity_request_handler(Self::handle_load_commit_diff);
@@ -589,6 +591,8 @@ impl GitStore {
client.add_entity_request_handler(Self::handle_remove_worktree);
client.add_entity_request_handler(Self::handle_rename_worktree);
client.add_entity_request_handler(Self::handle_get_head_sha);
client.add_entity_request_handler(Self::handle_edit_ref);
client.add_entity_request_handler(Self::handle_repair_worktrees);
}
pub fn is_local(&self) -> bool {
@@ -2519,6 +2523,46 @@ impl GitStore {
Ok(proto::GitGetHeadShaResponse { sha: head_sha })
}
async fn handle_edit_ref(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitEditRef>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
let ref_name = envelope.payload.ref_name;
let commit = match envelope.payload.action {
Some(proto::git_edit_ref::Action::UpdateToCommit(sha)) => Some(sha),
Some(proto::git_edit_ref::Action::Delete(_)) => None,
None => anyhow::bail!("GitEditRef missing action"),
};
repository_handle
.update(&mut cx, |repository_handle, _| {
repository_handle.edit_ref(ref_name, commit)
})
.await??;
Ok(proto::Ack {})
}
async fn handle_repair_worktrees(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitRepairWorktrees>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
repository_handle
.update(&mut cx, |repository_handle, _| {
repository_handle.repair_worktrees()
})
.await??;
Ok(proto::Ack {})
}
async fn handle_get_branches(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitGetBranches>,
@@ -2705,6 +2749,26 @@ impl GitStore {
})
}
async fn handle_create_archive_checkpoint(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitCreateArchiveCheckpoint>,
mut cx: AsyncApp,
) -> Result<proto::GitCreateArchiveCheckpointResponse> {
let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
let (staged_commit_sha, unstaged_commit_sha) = repository_handle
.update(&mut cx, |repository, _| {
repository.create_archive_checkpoint()
})
.await??;
Ok(proto::GitCreateArchiveCheckpointResponse {
staged_commit_sha,
unstaged_commit_sha,
})
}
async fn handle_restore_checkpoint(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitRestoreCheckpoint>,
@@ -2726,6 +2790,25 @@ impl GitStore {
Ok(proto::Ack {})
}
async fn handle_restore_archive_checkpoint(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitRestoreArchiveCheckpoint>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
let staged_commit_sha = envelope.payload.staged_commit_sha;
let unstaged_commit_sha = envelope.payload.unstaged_commit_sha;
repository_handle
.update(&mut cx, |repository, _| {
repository.restore_archive_checkpoint(staged_commit_sha, unstaged_commit_sha)
})
.await??;
Ok(proto::Ack {})
}
async fn handle_compare_checkpoints(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitCompareCheckpoints>,
@@ -6147,59 +6230,86 @@ impl Repository {
})
}
fn edit_ref(
&mut self,
ref_name: String,
commit: Option<String>,
) -> oneshot::Receiver<Result<()>> {
let id = self.id;
self.send_job(None, move |repo, _cx| async move {
match repo {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => match commit {
Some(commit) => backend.update_ref(ref_name, commit).await,
None => backend.delete_ref(ref_name).await,
},
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
let action = match commit {
Some(sha) => proto::git_edit_ref::Action::UpdateToCommit(sha),
None => {
proto::git_edit_ref::Action::Delete(proto::git_edit_ref::DeleteRef {})
}
};
client
.request(proto::GitEditRef {
project_id: project_id.0,
repository_id: id.to_proto(),
ref_name,
action: Some(action),
})
.await?;
Ok(())
}
}
})
}
pub fn update_ref(
&mut self,
ref_name: String,
commit: String,
) -> oneshot::Receiver<Result<()>> {
self.send_job(None, move |repo, _cx| async move {
match repo {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
backend.update_ref(ref_name, commit).await
}
RepositoryState::Remote(_) => {
anyhow::bail!("update_ref is not supported for remote repositories")
}
}
})
self.edit_ref(ref_name, Some(commit))
}
pub fn delete_ref(&mut self, ref_name: String) -> oneshot::Receiver<Result<()>> {
self.send_job(None, move |repo, _cx| async move {
match repo {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
backend.delete_ref(ref_name).await
}
RepositoryState::Remote(_) => {
anyhow::bail!("delete_ref is not supported for remote repositories")
}
}
})
self.edit_ref(ref_name, None)
}
pub fn repair_worktrees(&mut self) -> oneshot::Receiver<Result<()>> {
let id = self.id;
self.send_job(None, move |repo, _cx| async move {
match repo {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
backend.repair_worktrees().await
}
RepositoryState::Remote(_) => {
anyhow::bail!("repair_worktrees is not supported for remote repositories")
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
client
.request(proto::GitRepairWorktrees {
project_id: project_id.0,
repository_id: id.to_proto(),
})
.await?;
Ok(())
}
}
})
}
pub fn create_archive_checkpoint(&mut self) -> oneshot::Receiver<Result<(String, String)>> {
let id = self.id;
self.send_job(None, move |repo, _cx| async move {
match repo {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
backend.create_archive_checkpoint().await
}
RepositoryState::Remote(_) => {
anyhow::bail!(
"create_archive_checkpoint is not supported for remote repositories"
)
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
let response = client
.request(proto::GitCreateArchiveCheckpoint {
project_id: project_id.0,
repository_id: id.to_proto(),
})
.await?;
Ok((response.staged_commit_sha, response.unstaged_commit_sha))
}
}
})
@@ -6210,6 +6320,7 @@ impl Repository {
staged_sha: String,
unstaged_sha: String,
) -> oneshot::Receiver<Result<()>> {
let id = self.id;
self.send_job(None, move |repo, _cx| async move {
match repo {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
@@ -6217,10 +6328,16 @@ impl Repository {
.restore_archive_checkpoint(staged_sha, unstaged_sha)
.await
}
RepositoryState::Remote(_) => {
anyhow::bail!(
"restore_archive_checkpoint is not supported for remote repositories"
)
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
client
.request(proto::GitRestoreArchiveCheckpoint {
project_id: project_id.0,
repository_id: id.to_proto(),
staged_commit_sha: staged_sha,
unstaged_commit_sha: unstaged_sha,
})
.await?;
Ok(())
}
}
})
+33
View File
@@ -577,6 +577,22 @@ message GitGetHeadShaResponse {
optional string sha = 1;
}
message GitEditRef {
uint64 project_id = 1;
uint64 repository_id = 2;
string ref_name = 3;
oneof action {
string update_to_commit = 4;
DeleteRef delete = 5;
}
message DeleteRef {}
}
message GitRepairWorktrees {
uint64 project_id = 1;
uint64 repository_id = 2;
}
message GitWorktreesResponse {
repeated Worktree worktrees = 1;
}
@@ -607,12 +623,29 @@ message GitCreateCheckpointResponse {
bytes commit_sha = 1;
}
message GitCreateArchiveCheckpoint {
uint64 project_id = 1;
uint64 repository_id = 2;
}
message GitCreateArchiveCheckpointResponse {
string staged_commit_sha = 1;
string unstaged_commit_sha = 2;
}
message GitRestoreCheckpoint {
uint64 project_id = 1;
uint64 repository_id = 2;
bytes commit_sha = 3;
}
message GitRestoreArchiveCheckpoint {
uint64 project_id = 1;
uint64 repository_id = 2;
string staged_commit_sha = 3;
string unstaged_commit_sha = 4;
}
message GitCompareCheckpoints {
uint64 project_id = 1;
uint64 repository_id = 2;
+6 -1
View File
@@ -476,7 +476,12 @@ message Envelope {
GitDiffCheckpoints git_diff_checkpoints = 438;
GitDiffCheckpointsResponse git_diff_checkpoints_response = 439;
GitGetHeadSha git_get_head_sha = 440;
GitGetHeadShaResponse git_get_head_sha_response = 441; // current max
GitGetHeadShaResponse git_get_head_sha_response = 441;
GitRepairWorktrees git_repair_worktrees = 442;
GitEditRef git_edit_ref = 443;
GitCreateArchiveCheckpoint git_create_archive_checkpoint = 444;
GitCreateArchiveCheckpointResponse git_create_archive_checkpoint_response = 445;
GitRestoreArchiveCheckpoint git_restore_archive_checkpoint = 446; // current max
}
reserved 87 to 88;
+16
View File
@@ -296,7 +296,10 @@ messages!(
(GitFileHistoryResponse, Background),
(GitCreateCheckpoint, Background),
(GitCreateCheckpointResponse, Background),
(GitCreateArchiveCheckpoint, Background),
(GitCreateArchiveCheckpointResponse, Background),
(GitRestoreCheckpoint, Background),
(GitRestoreArchiveCheckpoint, Background),
(GitCompareCheckpoints, Background),
(GitCompareCheckpointsResponse, Background),
(GitDiffCheckpoints, Background),
@@ -353,6 +356,8 @@ messages!(
(GitGetWorktrees, Background),
(GitGetHeadSha, Background),
(GitGetHeadShaResponse, Background),
(GitEditRef, Background),
(GitRepairWorktrees, Background),
(GitWorktreesResponse, Background),
(GitCreateWorktree, Background),
(GitRemoveWorktree, Background),
@@ -524,7 +529,12 @@ request_messages!(
(GitShow, GitCommitDetails),
(GitFileHistory, GitFileHistoryResponse),
(GitCreateCheckpoint, GitCreateCheckpointResponse),
(
GitCreateArchiveCheckpoint,
GitCreateArchiveCheckpointResponse
),
(GitRestoreCheckpoint, Ack),
(GitRestoreArchiveCheckpoint, Ack),
(GitCompareCheckpoints, GitCompareCheckpointsResponse),
(GitDiffCheckpoints, GitDiffCheckpointsResponse),
(GitReset, Ack),
@@ -561,6 +571,8 @@ request_messages!(
(RemoteStarted, Ack),
(GitGetWorktrees, GitWorktreesResponse),
(GitGetHeadSha, GitGetHeadShaResponse),
(GitEditRef, Ack),
(GitRepairWorktrees, Ack),
(GitCreateWorktree, Ack),
(GitRemoveWorktree, Ack),
(GitRenameWorktree, Ack),
@@ -753,6 +765,10 @@ entity_messages!(
NewExternalAgentVersionAvailable,
GitGetWorktrees,
GitGetHeadSha,
GitEditRef,
GitRepairWorktrees,
GitCreateArchiveCheckpoint,
GitRestoreArchiveCheckpoint,
GitCreateWorktree,
GitRemoveWorktree,
GitRenameWorktree,
@@ -1622,6 +1622,98 @@ async fn test_remote_root_repo_common_dir(cx: &mut TestAppContext, server_cx: &m
assert_eq!(common_dir, None);
}
#[gpui::test]
async fn test_remote_archive_git_operations_are_supported(
cx: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
let fs = FakeFs::new(server_cx.executor());
fs.insert_tree(
"/project",
json!({
".git": {},
"file.txt": "content",
}),
)
.await;
fs.set_branch_name(Path::new("/project/.git"), Some("main"));
fs.set_head_for_repo(
Path::new("/project/.git"),
&[("file.txt", "content".into())],
"head-sha",
);
let (project, _headless) = init_test(&fs, cx, server_cx).await;
project
.update(cx, |project, cx| {
project.find_or_create_worktree(Path::new("/project"), true, cx)
})
.await
.expect("should open remote worktree");
cx.run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project
.active_repository(cx)
.expect("remote project should have an active repository")
});
let head_sha = cx
.update(|cx| repository.update(cx, |repository, _| repository.head_sha()))
.await
.expect("head_sha request should complete")
.expect("head_sha should succeed")
.expect("HEAD should exist");
cx.run_until_parked();
cx.update(|cx| {
repository.update(cx, |repository, _| {
repository.update_ref("refs/zed-tests/archive-checkpoint".to_string(), head_sha)
})
})
.await
.expect("update_ref request should complete")
.expect("update_ref should succeed for remote repository");
cx.run_until_parked();
cx.update(|cx| {
repository.update(cx, |repository, _| {
repository.delete_ref("refs/zed-tests/archive-checkpoint".to_string())
})
})
.await
.expect("delete_ref request should complete")
.expect("delete_ref should succeed for remote repository");
cx.run_until_parked();
cx.update(|cx| repository.update(cx, |repository, _| repository.repair_worktrees()))
.await
.expect("repair_worktrees request should complete")
.expect("repair_worktrees should succeed for remote repository");
cx.run_until_parked();
let (staged_commit_sha, unstaged_commit_sha) = cx
.update(|cx| repository.update(cx, |repository, _| repository.create_archive_checkpoint()))
.await
.expect("create_archive_checkpoint request should complete")
.expect("create_archive_checkpoint should succeed for remote repository");
cx.run_until_parked();
cx.update(|cx| {
repository.update(cx, |repository, _| {
repository.restore_archive_checkpoint(staged_commit_sha, unstaged_commit_sha)
})
})
.await
.expect("restore_archive_checkpoint request should complete")
.expect("restore_archive_checkpoint should succeed for remote repository");
}
#[gpui::test]
async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
let text_2 = "