@@ -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"
+ );
+ }
+}
@@ -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<()>> {