Add allow_empty to CommitOptions, detached worktree support, and new git operations

Richard Feldman created

- Add allow_empty field to CommitOptions and proto CommitOptions
- Change create_worktree trait method to accept Option<String> branch
  name (None means detached HEAD via --detach)
- Add update_ref, delete_ref, and stage_all_including_untracked to the
  GitRepository trait with implementations for RealGitRepository

Change summary

crates/git/src/repository.rs | 84 +++++++++++++++++++++++++++++++------
crates/proto/proto/git.proto |  1 
2 files changed, 71 insertions(+), 14 deletions(-)

Detailed changes

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 stage_all_including_untracked(&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,50 @@ 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![
+                    "--no-optional-locks".into(),
+                    "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![
+                    "--no-optional-locks".into(),
+                    "update-ref".into(),
+                    "-d".into(),
+                    ref_name.into(),
+                ];
+                git_binary?.run(&args).await?;
+                Ok(())
+            })
+            .boxed()
+    }
+
+    fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>> {
+        let git_binary = self.git_binary();
+        self.executor
+            .spawn(async move {
+                let args: Vec<OsString> =
+                    vec!["--no-optional-locks".into(), "add".into(), "-A".into()];
+                git_binary?.run(&args).await?;
+                Ok(())
+            })
+            .boxed()
+    }
+
     fn push(
         &self,
         branch_name: String,
@@ -4009,7 +4065,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 +4124,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 +4148,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 +4218,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()),
         )