Add allow_empty commits, detached worktree creation, and new git operations (#53213)

Richard Feldman and Anthony Eid created

Extend the git API with several new capabilities needed for worktree
archival and restoration:

- Add `allow_empty` flag to `CommitOptions` for creating WIP marker
commits
- Change `create_worktree` to accept `Option<String>` branch, enabling
detached worktree creation when `None` is passed
- Add `head_sha()` to read the current HEAD commit hash
- Add `update_ref()` and `delete_ref()` for managing git references
- Add `stage_all_including_untracked()` to stage everything before a WIP
commit
- Implement all new operations in `FakeGitRepository` with functional
commit history tracking, reset support, and ref management
- Update existing call sites for the new `CommitOptions` field and
`create_worktree` signature

Part 1 of 3 in the persist-worktree stack. These are nonbreaking API
additions with no behavioral changes to existing code.

Release Notes:

- N/A

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>

Change summary

crates/collab/src/rpc.rs                     |   1 
crates/collab/tests/integration/git_tests.rs |  52 +++++
crates/fs/src/fake_git_repo.rs               | 128 +++++++++++-
crates/fs/tests/integration/fake_git_repo.rs |  12 
crates/git/src/repository.rs                 |  73 ++++++-
crates/git_ui/src/commit_modal.rs            |   1 
crates/git_ui/src/git_panel.rs               |   8 
crates/project/src/git_store.rs              | 214 +++++++++++++++++++--
crates/proto/proto/git.proto                 |  10 +
crates/proto/proto/zed.proto                 |   4 
crates/proto/src/proto.rs                    |   4 
11 files changed, 450 insertions(+), 57 deletions(-)

Detailed changes

crates/collab/src/rpc.rs 🔗

@@ -435,6 +435,7 @@ impl Server {
             .add_request_handler(forward_mutating_project_request::<proto::GitCreateRemote>)
             .add_request_handler(forward_mutating_project_request::<proto::GitRemoveRemote>)
             .add_request_handler(forward_read_only_project_request::<proto::GitGetWorktrees>)
+            .add_request_handler(forward_read_only_project_request::<proto::GitGetHeadSha>)
             .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>)

crates/collab/tests/integration/git_tests.rs 🔗

@@ -424,6 +424,58 @@ async fn test_remote_git_worktrees(
     );
 }
 
+#[gpui::test]
+async fn test_remote_git_head_sha(
+    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 local_head_sha = cx_a.update(|cx| {
+        project_a
+            .read(cx)
+            .active_repository(cx)
+            .unwrap()
+            .update(cx, |repository, _| repository.head_sha())
+    });
+    let local_head_sha = local_head_sha.await.unwrap().unwrap();
+
+    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 remote_head_sha = cx_b.update(|cx| {
+        project_b
+            .read(cx)
+            .active_repository(cx)
+            .unwrap()
+            .update(cx, |repository, _| repository.head_sha())
+    });
+    let remote_head_sha = remote_head_sha.await.unwrap();
+
+    assert_eq!(remote_head_sha.unwrap(), local_head_sha);
+}
+
 #[gpui::test]
 async fn test_linked_worktrees_sync(
     executor: BackgroundExecutor,

crates/fs/src/fake_git_repo.rs 🔗

@@ -36,8 +36,16 @@ pub struct FakeGitRepository {
     pub(crate) is_trusted: Arc<AtomicBool>,
 }
 
+#[derive(Debug, Clone)]
+pub struct FakeCommitSnapshot {
+    pub head_contents: HashMap<RepoPath, String>,
+    pub index_contents: HashMap<RepoPath, String>,
+    pub sha: String,
+}
+
 #[derive(Debug, Clone)]
 pub struct FakeGitRepositoryState {
+    pub commit_history: Vec<FakeCommitSnapshot>,
     pub event_emitter: smol::channel::Sender<PathBuf>,
     pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
     pub head_contents: HashMap<RepoPath, String>,
@@ -74,6 +82,7 @@ impl FakeGitRepositoryState {
             oids: Default::default(),
             remotes: HashMap::default(),
             graph_commits: Vec::new(),
+            commit_history: Vec::new(),
             stash_entries: Default::default(),
         }
     }
@@ -217,11 +226,52 @@ impl GitRepository for FakeGitRepository {
 
     fn reset(
         &self,
-        _commit: String,
-        _mode: ResetMode,
+        commit: String,
+        mode: ResetMode,
         _env: Arc<HashMap<String, String>>,
     ) -> BoxFuture<'_, Result<()>> {
-        unimplemented!()
+        self.with_state_async(true, move |state| {
+            let pop_count = if commit == "HEAD~" || commit == "HEAD^" {
+                1
+            } else if let Some(suffix) = commit.strip_prefix("HEAD~") {
+                suffix
+                    .parse::<usize>()
+                    .with_context(|| format!("Invalid HEAD~ offset: {commit}"))?
+            } else {
+                match state
+                    .commit_history
+                    .iter()
+                    .rposition(|entry| entry.sha == commit)
+                {
+                    Some(index) => state.commit_history.len() - index,
+                    None => anyhow::bail!("Unknown commit ref: {commit}"),
+                }
+            };
+
+            if pop_count == 0 || pop_count > state.commit_history.len() {
+                anyhow::bail!(
+                    "Cannot reset {pop_count} commit(s): only {} in history",
+                    state.commit_history.len()
+                );
+            }
+
+            let target_index = state.commit_history.len() - pop_count;
+            let snapshot = state.commit_history[target_index].clone();
+            state.commit_history.truncate(target_index);
+
+            match mode {
+                ResetMode::Soft => {
+                    state.head_contents = snapshot.head_contents;
+                }
+                ResetMode::Mixed => {
+                    state.head_contents = snapshot.head_contents;
+                    state.index_contents = state.head_contents.clone();
+                }
+            }
+
+            state.refs.insert("HEAD".into(), snapshot.sha);
+            Ok(())
+        })
     }
 
     fn checkout_files(
@@ -490,7 +540,7 @@ impl GitRepository for FakeGitRepository {
 
     fn create_worktree(
         &self,
-        branch_name: String,
+        branch_name: Option<String>,
         path: PathBuf,
         from_commit: Option<String>,
     ) -> BoxFuture<'_, Result<()>> {
@@ -505,8 +555,10 @@ impl GitRepository for FakeGitRepository {
                 if let Some(message) = &state.simulated_create_worktree_error {
                     anyhow::bail!("{message}");
                 }
-                if state.branches.contains(&branch_name) {
-                    bail!("a branch named '{}' already exists", branch_name);
+                if let Some(ref name) = branch_name {
+                    if state.branches.contains(name) {
+                        bail!("a branch named '{}' already exists", name);
+                    }
                 }
                 Ok(())
             })??;
@@ -515,13 +567,22 @@ impl GitRepository for FakeGitRepository {
             fs.create_dir(&path).await?;
 
             // Create .git/worktrees/<name>/ directory with HEAD, commondir, gitdir.
-            let ref_name = format!("refs/heads/{branch_name}");
-            let worktrees_entry_dir = common_dir_path.join("worktrees").join(&branch_name);
+            let worktree_entry_name = branch_name
+                .as_deref()
+                .unwrap_or_else(|| path.file_name().unwrap().to_str().unwrap());
+            let worktrees_entry_dir = common_dir_path.join("worktrees").join(worktree_entry_name);
             fs.create_dir(&worktrees_entry_dir).await?;
 
+            let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string());
+            let head_content = if let Some(ref branch_name) = branch_name {
+                let ref_name = format!("refs/heads/{branch_name}");
+                format!("ref: {ref_name}")
+            } else {
+                sha.clone()
+            };
             fs.write_file_internal(
                 worktrees_entry_dir.join("HEAD"),
-                format!("ref: {ref_name}").into_bytes(),
+                head_content.into_bytes(),
                 false,
             )?;
             fs.write_file_internal(
@@ -544,10 +605,12 @@ impl GitRepository for FakeGitRepository {
             )?;
 
             // Update git state: add ref and branch.
-            let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string());
             fs.with_git_state(&dot_git_path, true, move |state| {
-                state.refs.insert(ref_name, sha);
-                state.branches.insert(branch_name);
+                if let Some(branch_name) = branch_name {
+                    let ref_name = format!("refs/heads/{branch_name}");
+                    state.refs.insert(ref_name, sha);
+                    state.branches.insert(branch_name);
+                }
                 Ok::<(), anyhow::Error>(())
             })??;
             Ok(())
@@ -822,11 +885,30 @@ impl GitRepository for FakeGitRepository {
         &self,
         _message: gpui::SharedString,
         _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
-        _options: CommitOptions,
+        options: CommitOptions,
         _askpass: AskPassDelegate,
         _env: Arc<HashMap<String, String>>,
     ) -> BoxFuture<'_, Result<()>> {
-        async { Ok(()) }.boxed()
+        self.with_state_async(true, move |state| {
+            if !options.allow_empty && !options.amend && state.index_contents == state.head_contents
+            {
+                anyhow::bail!("nothing to commit (use allow_empty to create an empty commit)");
+            }
+
+            let old_sha = state.refs.get("HEAD").cloned().unwrap_or_default();
+            state.commit_history.push(FakeCommitSnapshot {
+                head_contents: state.head_contents.clone(),
+                index_contents: state.index_contents.clone(),
+                sha: old_sha,
+            });
+
+            state.head_contents = state.index_contents.clone();
+
+            let new_sha = format!("fake-commit-{}", state.commit_history.len());
+            state.refs.insert("HEAD".into(), new_sha);
+
+            Ok(())
+        })
     }
 
     fn run_hook(
@@ -1210,6 +1292,24 @@ impl GitRepository for FakeGitRepository {
         anyhow::bail!("commit_data_reader not supported 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(())
+        })
+    }
+
+    fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
+        self.with_state_async(true, move |state| {
+            state.refs.remove(&ref_name);
+            Ok(())
+        })
+    }
+
+    fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> {
+        async { Ok(()) }.boxed()
+    }
+
     fn set_trusted(&self, trusted: bool) {
         self.is_trusted
             .store(trusted, std::sync::atomic::Ordering::Release);

crates/fs/tests/integration/fake_git_repo.rs 🔗

@@ -24,7 +24,7 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
     // Create a worktree
     let worktree_1_dir = worktrees_dir.join("feature-branch");
     repo.create_worktree(
-        "feature-branch".to_string(),
+        Some("feature-branch".to_string()),
         worktree_1_dir.clone(),
         Some("abc123".to_string()),
     )
@@ -47,9 +47,13 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
 
     // Create a second worktree (without explicit commit)
     let worktree_2_dir = worktrees_dir.join("bugfix-branch");
-    repo.create_worktree("bugfix-branch".to_string(), worktree_2_dir.clone(), None)
-        .await
-        .unwrap();
+    repo.create_worktree(
+        Some("bugfix-branch".to_string()),
+        worktree_2_dir.clone(),
+        None,
+    )
+    .await
+    .unwrap();
 
     let worktrees = repo.worktrees().await.unwrap();
     assert_eq!(worktrees.len(), 3);

crates/git/src/repository.rs 🔗

@@ -329,6 +329,7 @@ impl Upstream {
 pub struct CommitOptions {
     pub amend: bool,
     pub signoff: bool,
+    pub allow_empty: bool,
 }
 
 #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
@@ -715,7 +716,7 @@ pub trait GitRepository: Send + Sync {
 
     fn create_worktree(
         &self,
-        branch_name: String,
+        branch_name: Option<String>,
         path: PathBuf,
         from_commit: Option<String>,
     ) -> BoxFuture<'_, Result<()>>;
@@ -916,6 +917,12 @@ pub trait GitRepository: Send + Sync {
 
     fn commit_data_reader(&self) -> Result<CommitDataReader>;
 
+    fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>>;
+
+    fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>>;
+
+    fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>>;
+
     fn set_trusted(&self, trusted: bool);
     fn is_trusted(&self) -> bool;
 }
@@ -1660,19 +1667,20 @@ impl GitRepository for RealGitRepository {
 
     fn create_worktree(
         &self,
-        branch_name: String,
+        branch_name: Option<String>,
         path: PathBuf,
         from_commit: Option<String>,
     ) -> BoxFuture<'_, Result<()>> {
         let git_binary = self.git_binary();
-        let mut args = vec![
-            OsString::from("worktree"),
-            OsString::from("add"),
-            OsString::from("-b"),
-            OsString::from(branch_name.as_str()),
-            OsString::from("--"),
-            OsString::from(path.as_os_str()),
-        ];
+        let mut args = vec![OsString::from("worktree"), OsString::from("add")];
+        if let Some(branch_name) = &branch_name {
+            args.push(OsString::from("-b"));
+            args.push(OsString::from(branch_name.as_str()));
+        } else {
+            args.push(OsString::from("--detach"));
+        }
+        args.push(OsString::from("--"));
+        args.push(OsString::from(path.as_os_str()));
         if let Some(from_commit) = from_commit {
             args.push(OsString::from(from_commit));
         } else {
@@ -2165,6 +2173,10 @@ impl GitRepository for RealGitRepository {
                 cmd.arg("--signoff");
             }
 
+            if options.allow_empty {
+                cmd.arg("--allow-empty");
+            }
+
             if let Some((name, email)) = name_and_email {
                 cmd.arg("--author").arg(&format!("{name} <{email}>"));
             }
@@ -2176,6 +2188,39 @@ impl GitRepository for RealGitRepository {
         .boxed()
     }
 
+    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()
+    }
+
+    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()
+    }
+
+    fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> {
+        let git_binary = self.git_binary();
+        self.executor
+            .spawn(async move {
+                let args: Vec<OsString> = vec!["worktree".into(), "repair".into()];
+                git_binary?.run(&args).await?;
+                Ok(())
+            })
+            .boxed()
+    }
+
     fn push(
         &self,
         branch_name: String,
@@ -4009,7 +4054,7 @@ mod tests {
 
         // Create a new worktree
         repo.create_worktree(
-            "test-branch".to_string(),
+            Some("test-branch".to_string()),
             worktree_path.clone(),
             Some("HEAD".to_string()),
         )
@@ -4068,7 +4113,7 @@ mod tests {
         // Create a worktree
         let worktree_path = worktrees_dir.join("worktree-to-remove");
         repo.create_worktree(
-            "to-remove".to_string(),
+            Some("to-remove".to_string()),
             worktree_path.clone(),
             Some("HEAD".to_string()),
         )
@@ -4092,7 +4137,7 @@ mod tests {
         // Create a worktree
         let worktree_path = worktrees_dir.join("dirty-wt");
         repo.create_worktree(
-            "dirty-wt".to_string(),
+            Some("dirty-wt".to_string()),
             worktree_path.clone(),
             Some("HEAD".to_string()),
         )
@@ -4162,7 +4207,7 @@ mod tests {
         // Create a worktree
         let old_path = worktrees_dir.join("old-worktree-name");
         repo.create_worktree(
-            "old-name".to_string(),
+            Some("old-name".to_string()),
             old_path.clone(),
             Some("HEAD".to_string()),
         )

crates/git_ui/src/commit_modal.rs 🔗

@@ -453,6 +453,7 @@ impl CommitModal {
                                     CommitOptions {
                                         amend: is_amend_pending,
                                         signoff: is_signoff_enabled,
+                                        allow_empty: false,
                                     },
                                     window,
                                     cx,

crates/git_ui/src/git_panel.rs 🔗

@@ -2155,6 +2155,7 @@ impl GitPanel {
                 CommitOptions {
                     amend: false,
                     signoff: self.signoff_enabled,
+                    allow_empty: false,
                 },
                 window,
                 cx,
@@ -2195,6 +2196,7 @@ impl GitPanel {
                         CommitOptions {
                             amend: true,
                             signoff: self.signoff_enabled,
+                            allow_empty: false,
                         },
                         window,
                         cx,
@@ -4454,7 +4456,11 @@ impl GitPanel {
                         git_panel
                             .update(cx, |git_panel, cx| {
                                 git_panel.commit_changes(
-                                    CommitOptions { amend, signoff },
+                                    CommitOptions {
+                                        amend,
+                                        signoff,
+                                        allow_empty: false,
+                                    },
                                     window,
                                     cx,
                                 );

crates/project/src/git_store.rs 🔗

@@ -329,6 +329,12 @@ pub struct GraphDataResponse<'a> {
     pub error: Option<SharedString>,
 }
 
+#[derive(Clone, Debug)]
+enum CreateWorktreeStartPoint {
+    Detached,
+    Branched { name: String },
+}
+
 pub struct Repository {
     this: WeakEntity<Self>,
     snapshot: RepositorySnapshot,
@@ -588,6 +594,7 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_create_worktree);
         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);
     }
 
     pub fn is_local(&self) -> bool {
@@ -2340,6 +2347,7 @@ impl GitStore {
                     CommitOptions {
                         amend: options.amend,
                         signoff: options.signoff,
+                        allow_empty: options.allow_empty,
                     },
                     askpass,
                     cx,
@@ -2406,12 +2414,18 @@ impl GitStore {
         let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
         let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
         let directory = PathBuf::from(envelope.payload.directory);
-        let name = envelope.payload.name;
+        let start_point = if envelope.payload.name.is_empty() {
+            CreateWorktreeStartPoint::Detached
+        } else {
+            CreateWorktreeStartPoint::Branched {
+                name: envelope.payload.name,
+            }
+        };
         let commit = envelope.payload.commit;
 
         repository_handle
             .update(&mut cx, |repository_handle, _| {
-                repository_handle.create_worktree(name, directory, commit)
+                repository_handle.create_worktree_with_start_point(start_point, directory, commit)
             })
             .await??;
 
@@ -2456,6 +2470,21 @@ impl GitStore {
         Ok(proto::Ack {})
     }
 
+    async fn handle_get_head_sha(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitGetHeadSha>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GitGetHeadShaResponse> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+
+        let head_sha = repository_handle
+            .update(&mut cx, |repository_handle, _| repository_handle.head_sha())
+            .await??;
+
+        Ok(proto::GitGetHeadShaResponse { sha: head_sha })
+    }
+
     async fn handle_get_branches(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::GitGetBranches>,
@@ -5493,6 +5522,7 @@ impl Repository {
                             options: Some(proto::commit::CommitOptions {
                                 amend: options.amend,
                                 signoff: options.signoff,
+                                allow_empty: options.allow_empty,
                             }),
                             askpass_id,
                         })
@@ -5974,36 +6004,174 @@ impl Repository {
         })
     }
 
+    fn create_worktree_with_start_point(
+        &mut self,
+        start_point: CreateWorktreeStartPoint,
+        path: PathBuf,
+        commit: Option<String>,
+    ) -> oneshot::Receiver<Result<()>> {
+        if matches!(
+            &start_point,
+            CreateWorktreeStartPoint::Branched { name } if name.is_empty()
+        ) {
+            let (sender, receiver) = oneshot::channel();
+            sender
+                .send(Err(anyhow!("branch name cannot be empty")))
+                .ok();
+            return receiver;
+        }
+
+        let id = self.id;
+        let message = match &start_point {
+            CreateWorktreeStartPoint::Detached => "git worktree add (detached)".into(),
+            CreateWorktreeStartPoint::Branched { name } => {
+                format!("git worktree add: {name}").into()
+            }
+        };
+
+        self.send_job(Some(message), move |repo, _cx| async move {
+            let branch_name = match start_point {
+                CreateWorktreeStartPoint::Detached => None,
+                CreateWorktreeStartPoint::Branched { name } => Some(name),
+            };
+            let remote_name = branch_name.clone().unwrap_or_default();
+
+            match repo {
+                RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                    backend.create_worktree(branch_name, path, commit).await
+                }
+                RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                    client
+                        .request(proto::GitCreateWorktree {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                            name: remote_name,
+                            directory: path.to_string_lossy().to_string(),
+                            commit,
+                        })
+                        .await?;
+
+                    Ok(())
+                }
+            }
+        })
+    }
+
     pub fn create_worktree(
         &mut self,
         branch_name: String,
         path: PathBuf,
         commit: Option<String>,
     ) -> oneshot::Receiver<Result<()>> {
+        self.create_worktree_with_start_point(
+            CreateWorktreeStartPoint::Branched { name: branch_name },
+            path,
+            commit,
+        )
+    }
+
+    pub fn create_worktree_detached(
+        &mut self,
+        path: PathBuf,
+        commit: String,
+    ) -> oneshot::Receiver<Result<()>> {
+        self.create_worktree_with_start_point(
+            CreateWorktreeStartPoint::Detached,
+            path,
+            Some(commit),
+        )
+    }
+
+    pub fn head_sha(&mut self) -> oneshot::Receiver<Result<Option<String>>> {
         let id = self.id;
-        self.send_job(
-            Some(format!("git worktree add: {}", branch_name).into()),
-            move |repo, _cx| async move {
-                match repo {
-                    RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
-                        backend.create_worktree(branch_name, path, commit).await
-                    }
-                    RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
-                        client
-                            .request(proto::GitCreateWorktree {
-                                project_id: project_id.0,
-                                repository_id: id.to_proto(),
-                                name: branch_name,
-                                directory: path.to_string_lossy().to_string(),
-                                commit,
-                            })
-                            .await?;
+        self.send_job(None, move |repo, _cx| async move {
+            match repo {
+                RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                    Ok(backend.head_sha().await)
+                }
+                RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                    let response = client
+                        .request(proto::GitGetHeadSha {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                        })
+                        .await?;
 
-                        Ok(())
-                    }
+                    Ok(response.sha)
                 }
-            },
-        )
+            }
+        })
+    }
+
+    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")
+                }
+            }
+        })
+    }
+
+    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")
+                }
+            }
+        })
+    }
+
+    pub fn resolve_commit(&mut self, sha: String) -> oneshot::Receiver<Result<bool>> {
+        self.send_job(None, move |repo, _cx| async move {
+            match repo {
+                RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                    let results = backend.revparse_batch(vec![sha]).await?;
+                    Ok(results.into_iter().next().flatten().is_some())
+                }
+                RepositoryState::Remote(_) => {
+                    anyhow::bail!("resolve_commit is not supported for remote repositories")
+                }
+            }
+        })
+    }
+
+    pub fn repair_worktrees(&mut self) -> oneshot::Receiver<Result<()>> {
+        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")
+                }
+            }
+        })
+    }
+
+    pub fn commit_exists(&mut self, sha: String) -> oneshot::Receiver<Result<bool>> {
+        self.send_job(None, move |repo, _cx| async move {
+            match repo {
+                RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                    let results = backend.revparse_batch(vec![sha]).await?;
+                    Ok(results.into_iter().next().flatten().is_some())
+                }
+                RepositoryState::Remote(_) => {
+                    anyhow::bail!("commit_exists is not supported for remote repositories")
+                }
+            }
+        })
     }
 
     pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver<Result<()>> {

crates/proto/proto/git.proto 🔗

@@ -403,6 +403,7 @@ message Commit {
   message CommitOptions {
     bool amend = 1;
     bool signoff = 2;
+    bool allow_empty = 3;
   }
 }
 
@@ -567,6 +568,15 @@ message GitGetWorktrees {
   uint64 repository_id = 2;
 }
 
+message GitGetHeadSha {
+  uint64 project_id = 1;
+  uint64 repository_id = 2;
+}
+
+message GitGetHeadShaResponse {
+  optional string sha = 1;
+}
+
 message GitWorktreesResponse {
   repeated Worktree worktrees = 1;
 }

crates/proto/proto/zed.proto 🔗

@@ -474,7 +474,9 @@ message Envelope {
     GitCompareCheckpoints git_compare_checkpoints = 436;
     GitCompareCheckpointsResponse git_compare_checkpoints_response = 437;
     GitDiffCheckpoints git_diff_checkpoints = 438;
-    GitDiffCheckpointsResponse git_diff_checkpoints_response = 439; // current max
+    GitDiffCheckpointsResponse git_diff_checkpoints_response = 439;
+    GitGetHeadSha git_get_head_sha = 440;
+    GitGetHeadShaResponse git_get_head_sha_response = 441; // current max
   }
 
   reserved 87 to 88;

crates/proto/src/proto.rs 🔗

@@ -351,6 +351,8 @@ messages!(
     (NewExternalAgentVersionAvailable, Background),
     (RemoteStarted, Background),
     (GitGetWorktrees, Background),
+    (GitGetHeadSha, Background),
+    (GitGetHeadShaResponse, Background),
     (GitWorktreesResponse, Background),
     (GitCreateWorktree, Background),
     (GitRemoveWorktree, Background),
@@ -558,6 +560,7 @@ request_messages!(
     (GetContextServerCommand, ContextServerCommand),
     (RemoteStarted, Ack),
     (GitGetWorktrees, GitWorktreesResponse),
+    (GitGetHeadSha, GitGetHeadShaResponse),
     (GitCreateWorktree, Ack),
     (GitRemoveWorktree, Ack),
     (GitRenameWorktree, Ack),
@@ -749,6 +752,7 @@ entity_messages!(
     ExternalAgentLoadingStatusUpdated,
     NewExternalAgentVersionAvailable,
     GitGetWorktrees,
+    GitGetHeadSha,
     GitCreateWorktree,
     GitRemoveWorktree,
     GitRenameWorktree,