Add git worktree remove/rename API (#49135)

Richard Feldman created

Add `remove_worktree()` and `rename_worktree()` to the `GitRepository`
trait with `RealGitRepository` implementations that shell out to `git
worktree remove/move`.

Implement all 4 worktree methods (`worktrees`, `create_worktree`,
`remove_worktree`, `rename_worktree`) on `FakeGitRepository` backed by
`FakeGitRepositoryState`, with `simulated_create_worktree_error` for
test-time fault injection.

Add `set_create_worktree_error()` helper on `FakeFs`.

Add `parse_worktrees_from_str` helper and 7 new tests covering real git
operations and fake worktree lifecycle.

Closes AI-31

Release Notes:

- N/A

Change summary

crates/fs/src/fake_git_repo.rs | 262 +++++++++++++++++++++
crates/fs/src/fs.rs            |   7 
crates/git/src/repository.rs   | 433 ++++++++++++++++++++++++++++++++++-
3 files changed, 679 insertions(+), 23 deletions(-)

Detailed changes

crates/fs/src/fake_git_repo.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{FakeFs, FakeFsEntry, Fs};
+use crate::{FakeFs, FakeFsEntry, Fs, RemoveOptions, RenameOptions};
 use anyhow::{Context as _, Result, bail};
 use collections::{HashMap, HashSet};
 use futures::future::{self, BoxFuture, join_all};
@@ -49,8 +49,10 @@ pub struct FakeGitRepositoryState {
     /// List of remotes, keys are names and values are URLs
     pub remotes: HashMap<String, String>,
     pub simulated_index_write_error_message: Option<String>,
+    pub simulated_create_worktree_error: Option<String>,
     pub refs: HashMap<String, String>,
     pub graph_commits: Vec<Arc<InitialGraphCommitData>>,
+    pub worktrees: Vec<Worktree>,
 }
 
 impl FakeGitRepositoryState {
@@ -64,11 +66,13 @@ impl FakeGitRepositoryState {
             current_branch_name: Default::default(),
             branches: Default::default(),
             simulated_index_write_error_message: Default::default(),
+            simulated_create_worktree_error: Default::default(),
             refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
             merge_base_contents: Default::default(),
             oids: Default::default(),
             remotes: HashMap::default(),
             graph_commits: Vec::new(),
+            worktrees: Vec::new(),
         }
     }
 }
@@ -402,16 +406,129 @@ impl GitRepository for FakeGitRepository {
     }
 
     fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
-        unimplemented!()
+        self.with_state_async(false, |state| Ok(state.worktrees.clone()))
     }
 
     fn create_worktree(
         &self,
-        _: String,
-        _: PathBuf,
-        _: Option<String>,
+        name: String,
+        directory: PathBuf,
+        from_commit: Option<String>,
     ) -> BoxFuture<'_, Result<()>> {
-        unimplemented!()
+        let fs = self.fs.clone();
+        let executor = self.executor.clone();
+        let dot_git_path = self.dot_git_path.clone();
+        async move {
+            let path = directory.join(&name);
+            executor.simulate_random_delay().await;
+            // Check for simulated error before any side effects
+            fs.with_git_state(&dot_git_path, false, |state| {
+                if let Some(message) = &state.simulated_create_worktree_error {
+                    anyhow::bail!("{message}");
+                }
+                Ok(())
+            })??;
+            // Create directory before updating state so state is never
+            // inconsistent with the filesystem
+            fs.create_dir(&path).await?;
+            fs.with_git_state(&dot_git_path, true, {
+                let path = path.clone();
+                move |state| {
+                    if state.branches.contains(&name) {
+                        bail!("a branch named '{}' already exists", name);
+                    }
+                    let ref_name = format!("refs/heads/{name}");
+                    let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string());
+                    state.refs.insert(ref_name.clone(), sha.clone());
+                    state.worktrees.push(Worktree {
+                        path,
+                        ref_name: ref_name.into(),
+                        sha: sha.into(),
+                    });
+                    state.branches.insert(name);
+                    Ok::<(), anyhow::Error>(())
+                }
+            })??;
+            Ok(())
+        }
+        .boxed()
+    }
+
+    fn remove_worktree(&self, path: PathBuf, _force: bool) -> BoxFuture<'_, Result<()>> {
+        let fs = self.fs.clone();
+        let executor = self.executor.clone();
+        let dot_git_path = self.dot_git_path.clone();
+        async move {
+            executor.simulate_random_delay().await;
+            // Validate the worktree exists in state before touching the filesystem
+            fs.with_git_state(&dot_git_path, false, {
+                let path = path.clone();
+                move |state| {
+                    if !state.worktrees.iter().any(|w| w.path == path) {
+                        bail!("no worktree found at path: {}", path.display());
+                    }
+                    Ok(())
+                }
+            })??;
+            // Now remove the directory
+            fs.remove_dir(
+                &path,
+                RemoveOptions {
+                    recursive: true,
+                    ignore_if_not_exists: false,
+                },
+            )
+            .await?;
+            // Update state
+            fs.with_git_state(&dot_git_path, true, move |state| {
+                state.worktrees.retain(|worktree| worktree.path != path);
+                Ok::<(), anyhow::Error>(())
+            })??;
+            Ok(())
+        }
+        .boxed()
+    }
+
+    fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> {
+        let fs = self.fs.clone();
+        let executor = self.executor.clone();
+        let dot_git_path = self.dot_git_path.clone();
+        async move {
+            executor.simulate_random_delay().await;
+            // Validate the worktree exists in state before touching the filesystem
+            fs.with_git_state(&dot_git_path, false, {
+                let old_path = old_path.clone();
+                move |state| {
+                    if !state.worktrees.iter().any(|w| w.path == old_path) {
+                        bail!("no worktree found at path: {}", old_path.display());
+                    }
+                    Ok(())
+                }
+            })??;
+            // Now move the directory
+            fs.rename(
+                &old_path,
+                &new_path,
+                RenameOptions {
+                    overwrite: false,
+                    ignore_if_exists: false,
+                    create_parents: true,
+                },
+            )
+            .await?;
+            // Update state
+            fs.with_git_state(&dot_git_path, true, move |state| {
+                let worktree = state
+                    .worktrees
+                    .iter_mut()
+                    .find(|worktree| worktree.path == old_path)
+                    .expect("worktree was validated above");
+                worktree.path = new_path;
+                Ok::<(), anyhow::Error>(())
+            })??;
+            Ok(())
+        }
+        .boxed()
     }
 
     fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
@@ -765,3 +882,136 @@ impl GitRepository for FakeGitRepository {
         anyhow::bail!("commit_data_reader not supported for FakeGitRepository")
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{FakeFs, Fs};
+    use gpui::TestAppContext;
+    use serde_json::json;
+    use std::path::Path;
+
+    #[gpui::test]
+    async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project", json!({".git": {}, "file.txt": "content"}))
+            .await;
+        let repo = fs
+            .open_repo(Path::new("/project/.git"), None)
+            .expect("should open fake repo");
+
+        // Initially no worktrees
+        let worktrees = repo.worktrees().await.unwrap();
+        assert!(worktrees.is_empty());
+
+        // Create a worktree
+        repo.create_worktree(
+            "feature-branch".to_string(),
+            PathBuf::from("/worktrees"),
+            Some("abc123".to_string()),
+        )
+        .await
+        .unwrap();
+
+        // List worktrees — should have one
+        let worktrees = repo.worktrees().await.unwrap();
+        assert_eq!(worktrees.len(), 1);
+        assert_eq!(worktrees[0].path, Path::new("/worktrees/feature-branch"));
+        assert_eq!(worktrees[0].ref_name.as_ref(), "refs/heads/feature-branch");
+        assert_eq!(worktrees[0].sha.as_ref(), "abc123");
+
+        // Directory should exist in FakeFs after create
+        assert!(
+            fs.is_dir(Path::new("/worktrees/feature-branch")).await,
+            "worktree directory should be created in FakeFs"
+        );
+
+        // Create a second worktree (without explicit commit)
+        repo.create_worktree(
+            "bugfix-branch".to_string(),
+            PathBuf::from("/worktrees"),
+            None,
+        )
+        .await
+        .unwrap();
+
+        let worktrees = repo.worktrees().await.unwrap();
+        assert_eq!(worktrees.len(), 2);
+        assert!(
+            fs.is_dir(Path::new("/worktrees/bugfix-branch")).await,
+            "second worktree directory should be created in FakeFs"
+        );
+
+        // Rename the first worktree
+        repo.rename_worktree(
+            PathBuf::from("/worktrees/feature-branch"),
+            PathBuf::from("/worktrees/renamed-branch"),
+        )
+        .await
+        .unwrap();
+
+        let worktrees = repo.worktrees().await.unwrap();
+        assert_eq!(worktrees.len(), 2);
+        assert!(
+            worktrees
+                .iter()
+                .any(|w| w.path == Path::new("/worktrees/renamed-branch")),
+            "renamed worktree should exist at new path"
+        );
+        assert!(
+            worktrees
+                .iter()
+                .all(|w| w.path != Path::new("/worktrees/feature-branch")),
+            "old path should no longer exist"
+        );
+
+        // Directory should be moved in FakeFs after rename
+        assert!(
+            !fs.is_dir(Path::new("/worktrees/feature-branch")).await,
+            "old worktree directory should not exist after rename"
+        );
+        assert!(
+            fs.is_dir(Path::new("/worktrees/renamed-branch")).await,
+            "new worktree directory should exist after rename"
+        );
+
+        // Rename a nonexistent worktree should fail
+        let result = repo
+            .rename_worktree(PathBuf::from("/nonexistent"), PathBuf::from("/somewhere"))
+            .await;
+        assert!(result.is_err());
+
+        // Remove a worktree
+        repo.remove_worktree(PathBuf::from("/worktrees/renamed-branch"), false)
+            .await
+            .unwrap();
+
+        let worktrees = repo.worktrees().await.unwrap();
+        assert_eq!(worktrees.len(), 1);
+        assert_eq!(worktrees[0].path, Path::new("/worktrees/bugfix-branch"));
+
+        // Directory should be removed from FakeFs after remove
+        assert!(
+            !fs.is_dir(Path::new("/worktrees/renamed-branch")).await,
+            "worktree directory should be removed from FakeFs"
+        );
+
+        // Remove a nonexistent worktree should fail
+        let result = repo
+            .remove_worktree(PathBuf::from("/nonexistent"), false)
+            .await;
+        assert!(result.is_err());
+
+        // Remove the last worktree
+        repo.remove_worktree(PathBuf::from("/worktrees/bugfix-branch"), false)
+            .await
+            .unwrap();
+
+        let worktrees = repo.worktrees().await.unwrap();
+        assert!(worktrees.is_empty());
+        assert!(
+            !fs.is_dir(Path::new("/worktrees/bugfix-branch")).await,
+            "last worktree directory should be removed from FakeFs"
+        );
+    }
+}

crates/fs/src/fs.rs 🔗

@@ -2069,6 +2069,13 @@ impl FakeFs {
         .unwrap();
     }
 
+    pub fn set_create_worktree_error(&self, dot_git: &Path, message: Option<String>) {
+        self.with_git_state(dot_git, true, |state| {
+            state.simulated_create_worktree_error = message;
+        })
+        .unwrap();
+    }
+
     pub fn paths(&self, include_dot_git: bool) -> Vec<PathBuf> {
         let mut result = Vec::new();
         let mut queue = collections::VecDeque::new();

crates/git/src/repository.rs 🔗

@@ -203,18 +203,27 @@ impl Worktree {
 
 pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
     let mut worktrees = Vec::new();
-    let entries = raw_worktrees.as_ref().split("\n\n");
+    let normalized = raw_worktrees.as_ref().replace("\r\n", "\n");
+    let entries = normalized.split("\n\n");
     for entry in entries {
-        let mut parts = entry.splitn(3, '\n');
-        let path = parts
-            .next()
-            .and_then(|p| p.split_once(' ').map(|(_, path)| path.to_string()));
-        let sha = parts
-            .next()
-            .and_then(|p| p.split_once(' ').map(|(_, sha)| sha.to_string()));
-        let ref_name = parts
-            .next()
-            .and_then(|p| p.split_once(' ').map(|(_, ref_name)| ref_name.to_string()));
+        let mut path = None;
+        let mut sha = None;
+        let mut ref_name = None;
+
+        for line in entry.lines() {
+            let line = line.trim();
+            if line.is_empty() {
+                continue;
+            }
+            if let Some(rest) = line.strip_prefix("worktree ") {
+                path = Some(rest.to_string());
+            } else if let Some(rest) = line.strip_prefix("HEAD ") {
+                sha = Some(rest.to_string());
+            } else if let Some(rest) = line.strip_prefix("branch ") {
+                ref_name = Some(rest.to_string());
+            }
+            // Ignore other lines: detached, bare, locked, prunable, etc.
+        }
 
         if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) {
             worktrees.push(Worktree {
@@ -629,6 +638,10 @@ pub trait GitRepository: Send + Sync {
         from_commit: Option<String>,
     ) -> BoxFuture<'_, Result<()>>;
 
+    fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>>;
+
+    fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>>;
+
     fn reset(
         &self,
         commit: String,
@@ -1573,14 +1586,15 @@ impl GitRepository for RealGitRepository {
             OsString::from("--no-optional-locks"),
             OsString::from("worktree"),
             OsString::from("add"),
+            OsString::from("-b"),
+            OsString::from(name.as_str()),
+            OsString::from("--"),
             OsString::from(final_path.as_os_str()),
         ];
         if let Some(from_commit) = from_commit {
-            args.extend([
-                OsString::from("-b"),
-                OsString::from(name.as_str()),
-                OsString::from(from_commit),
-            ]);
+            args.push(OsString::from(from_commit));
+        } else {
+            args.push(OsString::from("HEAD"));
         }
         self.executor
             .spawn(async move {
@@ -1593,12 +1607,60 @@ impl GitRepository for RealGitRepository {
                     Ok(())
                 } else {
                     let stderr = String::from_utf8_lossy(&output.stderr);
-                    anyhow::bail!("git worktree list failed: {stderr}");
+                    anyhow::bail!("git worktree add failed: {stderr}");
                 }
             })
             .boxed()
     }
 
+    fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> {
+        let git_binary_path = self.any_git_binary_path.clone();
+        let working_directory = self.working_directory();
+        let executor = self.executor.clone();
+
+        self.executor
+            .spawn(async move {
+                let mut args: Vec<OsString> = vec![
+                    "--no-optional-locks".into(),
+                    "worktree".into(),
+                    "remove".into(),
+                ];
+                if force {
+                    args.push("--force".into());
+                }
+                args.push("--".into());
+                args.push(path.as_os_str().into());
+                GitBinary::new(git_binary_path, working_directory?, executor)
+                    .run(args)
+                    .await?;
+                anyhow::Ok(())
+            })
+            .boxed()
+    }
+
+    fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> {
+        let git_binary_path = self.any_git_binary_path.clone();
+        let working_directory = self.working_directory();
+        let executor = self.executor.clone();
+
+        self.executor
+            .spawn(async move {
+                let args: Vec<OsString> = vec![
+                    "--no-optional-locks".into(),
+                    "worktree".into(),
+                    "move".into(),
+                    "--".into(),
+                    old_path.as_os_str().into(),
+                    new_path.as_os_str().into(),
+                ];
+                GitBinary::new(git_binary_path, working_directory?, executor)
+                    .run(args)
+                    .await?;
+                anyhow::Ok(())
+            })
+            .boxed()
+    }
+
     fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
         let repo = self.repository.clone();
         let working_directory = self.working_directory();
@@ -3576,6 +3638,343 @@ mod tests {
         assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
     }
 
+    #[test]
+    fn test_parse_worktrees_from_str() {
+        // Empty input
+        let result = parse_worktrees_from_str("");
+        assert!(result.is_empty());
+
+        // Single worktree (main)
+        let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n";
+        let result = parse_worktrees_from_str(input);
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
+        assert_eq!(result[0].sha.as_ref(), "abc123def");
+        assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
+
+        // Multiple worktrees
+        let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
+                      worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n";
+        let result = parse_worktrees_from_str(input);
+        assert_eq!(result.len(), 2);
+        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
+        assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
+        assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt"));
+        assert_eq!(result[1].ref_name.as_ref(), "refs/heads/feature");
+
+        // Detached HEAD entry (should be skipped since ref_name won't parse)
+        let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
+                      worktree /home/user/detached\nHEAD def456\ndetached\n\n";
+        let result = parse_worktrees_from_str(input);
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
+
+        // Bare repo entry (should be skipped)
+        let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
+                      worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n";
+        let result = parse_worktrees_from_str(input);
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
+
+        // Extra porcelain lines (locked, prunable) should be ignored
+        let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
+                      worktree /home/user/locked-wt\nHEAD def456\nbranch refs/heads/locked-branch\nlocked\n\n\
+                      worktree /home/user/prunable-wt\nHEAD 789aaa\nbranch refs/heads/prunable-branch\nprunable\n\n";
+        let result = parse_worktrees_from_str(input);
+        assert_eq!(result.len(), 3);
+        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
+        assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
+        assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt"));
+        assert_eq!(result[1].ref_name.as_ref(), "refs/heads/locked-branch");
+        assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt"));
+        assert_eq!(result[2].ref_name.as_ref(), "refs/heads/prunable-branch");
+
+        // Leading/trailing whitespace on lines should be tolerated
+        let input =
+            "  worktree /home/user/project  \n  HEAD abc123  \n  branch refs/heads/main  \n\n";
+        let result = parse_worktrees_from_str(input);
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
+        assert_eq!(result[0].sha.as_ref(), "abc123");
+        assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
+
+        // Windows-style line endings should be handled
+        let input = "worktree /home/user/project\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\n";
+        let result = parse_worktrees_from_str(input);
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
+        assert_eq!(result[0].sha.as_ref(), "abc123");
+        assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
+    }
+
+    #[gpui::test]
+    async fn test_create_and_list_worktrees(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().unwrap();
+        git2::Repository::init(repo_dir.path()).unwrap();
+
+        let repo = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        // Create an initial commit (required for worktrees)
+        smol::fs::write(repo_dir.path().join("file.txt"), "content")
+            .await
+            .unwrap();
+        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
+            .await
+            .unwrap();
+        repo.commit(
+            "Initial commit".into(),
+            None,
+            CommitOptions::default(),
+            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
+            Arc::new(checkpoint_author_envs()),
+        )
+        .await
+        .unwrap();
+
+        // List worktrees — should have just the main one
+        let worktrees = repo.worktrees().await.unwrap();
+        assert_eq!(worktrees.len(), 1);
+        assert_eq!(
+            worktrees[0].path.canonicalize().unwrap(),
+            repo_dir.path().canonicalize().unwrap()
+        );
+
+        // Create a new worktree
+        let worktree_dir = tempfile::tempdir().unwrap();
+        repo.create_worktree(
+            "test-branch".to_string(),
+            worktree_dir.path().to_path_buf(),
+            Some("HEAD".to_string()),
+        )
+        .await
+        .unwrap();
+
+        // List worktrees — should have two
+        let worktrees = repo.worktrees().await.unwrap();
+        assert_eq!(worktrees.len(), 2);
+
+        let new_worktree = worktrees
+            .iter()
+            .find(|w| w.branch() == "test-branch")
+            .expect("should find worktree with test-branch");
+        assert_eq!(
+            new_worktree.path.canonicalize().unwrap(),
+            worktree_dir
+                .path()
+                .join("test-branch")
+                .canonicalize()
+                .unwrap()
+        );
+    }
+
+    #[gpui::test]
+    async fn test_remove_worktree(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().unwrap();
+        git2::Repository::init(repo_dir.path()).unwrap();
+
+        let repo = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        // Create an initial commit
+        smol::fs::write(repo_dir.path().join("file.txt"), "content")
+            .await
+            .unwrap();
+        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
+            .await
+            .unwrap();
+        repo.commit(
+            "Initial commit".into(),
+            None,
+            CommitOptions::default(),
+            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
+            Arc::new(checkpoint_author_envs()),
+        )
+        .await
+        .unwrap();
+
+        // Create a worktree
+        let worktree_dir = tempfile::tempdir().unwrap();
+        repo.create_worktree(
+            "to-remove".to_string(),
+            worktree_dir.path().to_path_buf(),
+            Some("HEAD".to_string()),
+        )
+        .await
+        .unwrap();
+
+        let worktree_path = worktree_dir.path().join("to-remove");
+        assert!(worktree_path.exists());
+
+        // Remove the worktree
+        repo.remove_worktree(worktree_path.clone(), false)
+            .await
+            .unwrap();
+
+        // Verify it's gone from the list
+        let worktrees = repo.worktrees().await.unwrap();
+        assert_eq!(worktrees.len(), 1);
+        assert!(
+            worktrees.iter().all(|w| w.branch() != "to-remove"),
+            "removed worktree should not appear in list"
+        );
+
+        // Verify the directory is removed
+        assert!(!worktree_path.exists());
+    }
+
+    #[gpui::test]
+    async fn test_remove_worktree_force(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().unwrap();
+        git2::Repository::init(repo_dir.path()).unwrap();
+
+        let repo = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        // Create an initial commit
+        smol::fs::write(repo_dir.path().join("file.txt"), "content")
+            .await
+            .unwrap();
+        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
+            .await
+            .unwrap();
+        repo.commit(
+            "Initial commit".into(),
+            None,
+            CommitOptions::default(),
+            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
+            Arc::new(checkpoint_author_envs()),
+        )
+        .await
+        .unwrap();
+
+        // Create a worktree
+        let worktree_dir = tempfile::tempdir().unwrap();
+        repo.create_worktree(
+            "dirty-wt".to_string(),
+            worktree_dir.path().to_path_buf(),
+            Some("HEAD".to_string()),
+        )
+        .await
+        .unwrap();
+
+        let worktree_path = worktree_dir.path().join("dirty-wt");
+
+        // Add uncommitted changes in the worktree
+        smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
+            .await
+            .unwrap();
+
+        // Non-force removal should fail with dirty worktree
+        let result = repo.remove_worktree(worktree_path.clone(), false).await;
+        assert!(
+            result.is_err(),
+            "non-force removal of dirty worktree should fail"
+        );
+
+        // Force removal should succeed
+        repo.remove_worktree(worktree_path.clone(), true)
+            .await
+            .unwrap();
+
+        let worktrees = repo.worktrees().await.unwrap();
+        assert_eq!(worktrees.len(), 1);
+        assert!(!worktree_path.exists());
+    }
+
+    #[gpui::test]
+    async fn test_rename_worktree(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let repo_dir = tempfile::tempdir().unwrap();
+        git2::Repository::init(repo_dir.path()).unwrap();
+
+        let repo = RealGitRepository::new(
+            &repo_dir.path().join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        // Create an initial commit
+        smol::fs::write(repo_dir.path().join("file.txt"), "content")
+            .await
+            .unwrap();
+        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
+            .await
+            .unwrap();
+        repo.commit(
+            "Initial commit".into(),
+            None,
+            CommitOptions::default(),
+            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
+            Arc::new(checkpoint_author_envs()),
+        )
+        .await
+        .unwrap();
+
+        // Create a worktree
+        let worktree_dir = tempfile::tempdir().unwrap();
+        repo.create_worktree(
+            "old-name".to_string(),
+            worktree_dir.path().to_path_buf(),
+            Some("HEAD".to_string()),
+        )
+        .await
+        .unwrap();
+
+        let old_path = worktree_dir.path().join("old-name");
+        assert!(old_path.exists());
+
+        // Move the worktree to a new path
+        let new_path = worktree_dir.path().join("new-name");
+        repo.rename_worktree(old_path.clone(), new_path.clone())
+            .await
+            .unwrap();
+
+        // Verify the old path is gone and new path exists
+        assert!(!old_path.exists());
+        assert!(new_path.exists());
+
+        // Verify it shows up in worktree list at the new path
+        let worktrees = repo.worktrees().await.unwrap();
+        assert_eq!(worktrees.len(), 2);
+        let moved_worktree = worktrees
+            .iter()
+            .find(|w| w.branch() == "old-name")
+            .expect("should find worktree by branch name");
+        assert_eq!(
+            moved_worktree.path.canonicalize().unwrap(),
+            new_path.canonicalize().unwrap()
+        );
+    }
+
     impl RealGitRepository {
         /// Force a Git garbage collection on the repository.
         fn gc(&self) -> BoxFuture<'_, Result<()>> {