Add allow_empty commits, detached worktree creation, and new git operations

Richard Feldman 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

Change summary

crates/fs/src/fake_git_repo.rs               | 161 ++++++++++++++++++++-
crates/fs/tests/integration/fake_git_repo.rs |  12 +
crates/git/src/repository.rs                 |  84 +++++++++-
crates/git_ui/src/commit_modal.rs            |   1 
crates/git_ui/src/git_panel.rs               |   8 
crates/project/src/git_store.rs              |  86 +++++++++++
crates/proto/proto/git.proto                 |   1 
7 files changed, 319 insertions(+), 34 deletions(-)

Detailed changes

crates/fs/src/fake_git_repo.rs 🔗

@@ -35,8 +35,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>,
@@ -72,6 +80,7 @@ impl FakeGitRepositoryState {
             oids: Default::default(),
             remotes: HashMap::default(),
             graph_commits: Vec::new(),
+            commit_history: Vec::new(),
         }
     }
 }
@@ -214,11 +223,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~" {
+                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(
@@ -483,7 +533,7 @@ impl GitRepository for FakeGitRepository {
 
     fn create_worktree(
         &self,
-        branch_name: String,
+        branch_name: Option<String>,
         path: PathBuf,
         from_commit: Option<String>,
     ) -> BoxFuture<'_, Result<()>> {
@@ -498,8 +548,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(())
             })??;
@@ -508,13 +560,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(
@@ -537,10 +598,14 @@ 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);
+                } else {
+                    state.refs.insert("HEAD".into(), sha);
+                }
                 Ok::<(), anyhow::Error>(())
             })??;
             Ok(())
@@ -815,11 +880,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(
@@ -1203,6 +1287,55 @@ 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 stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>> {
+        let workdir_path = self.dot_git_path.parent().unwrap();
+        let git_files: Vec<(RepoPath, String)> = self
+            .fs
+            .files()
+            .iter()
+            .filter_map(|path| {
+                let repo_path = path.strip_prefix(workdir_path).ok()?;
+                if repo_path.starts_with(".git") {
+                    return None;
+                }
+                let content = self
+                    .fs
+                    .read_file_sync(path)
+                    .ok()
+                    .and_then(|bytes| String::from_utf8(bytes).ok())?;
+                let rel_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
+                Some((RepoPath::from_rel_path(&rel_path), content))
+            })
+            .collect();
+
+        self.with_state_async(true, move |state| {
+            // Stage all filesystem contents, mirroring `git add -A`.
+            let fs_paths: HashSet<RepoPath> = git_files.iter().map(|(p, _)| p.clone()).collect();
+            for (path, content) in git_files {
+                state.index_contents.insert(path, content);
+            }
+            // Remove index entries for files that no longer exist on disk.
+            state
+                .index_contents
+                .retain(|path, _| fs_paths.contains(path));
+            Ok(())
+        })
+    }
+
     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 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()),
         )

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 🔗

@@ -2338,6 +2338,7 @@ impl GitStore {
                     CommitOptions {
                         amend: options.amend,
                         signoff: options.signoff,
+                        allow_empty: options.allow_empty,
                     },
                     askpass,
                     cx,
@@ -5484,6 +5485,7 @@ impl Repository {
                             options: Some(proto::commit::CommitOptions {
                                 amend: options.amend,
                                 signoff: options.signoff,
+                                allow_empty: options.allow_empty,
                             }),
                             askpass_id,
                         })
@@ -5977,7 +5979,9 @@ impl Repository {
             move |repo, _cx| async move {
                 match repo {
                     RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
-                        backend.create_worktree(branch_name, path, commit).await
+                        backend
+                            .create_worktree(Some(branch_name), path, commit)
+                            .await
                     }
                     RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
                         client
@@ -5997,6 +6001,86 @@ impl Repository {
         )
     }
 
+    pub fn create_worktree_detached(
+        &mut self,
+        path: PathBuf,
+        commit: String,
+    ) -> oneshot::Receiver<Result<()>> {
+        self.send_job(
+            Some("git worktree add (detached)".into()),
+            move |repo, _cx| async move {
+                match repo {
+                    RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                        backend.create_worktree(None, path, Some(commit)).await
+                    }
+                    RepositoryState::Remote(_) => {
+                        anyhow::bail!(
+                            "create_worktree_detached is not supported for remote repositories"
+                        )
+                    }
+                }
+            },
+        )
+    }
+
+    pub fn head_sha(&mut self) -> oneshot::Receiver<Result<Option<String>>> {
+        self.send_job(None, move |repo, _cx| async move {
+            match repo {
+                RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                    Ok(backend.head_sha().await)
+                }
+                RepositoryState::Remote(_) => {
+                    anyhow::bail!("head_sha is not supported for remote repositories")
+                }
+            }
+        })
+    }
+
+    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 stage_all_including_untracked(&mut self) -> oneshot::Receiver<Result<()>> {
+        self.send_job(None, move |repo, _cx| async move {
+            match repo {
+                RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                    backend.stage_all_including_untracked().await
+                }
+                RepositoryState::Remote(_) => {
+                    anyhow::bail!(
+                        "stage_all_including_untracked is not supported for remote repositories"
+                    )
+                }
+            }
+        })
+    }
+
     pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver<Result<()>> {
         let id = self.id;
         self.send_job(