From 91b319139ec8e00419e2533f6aed3df8f92247c7 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 17 Feb 2026 12:09:44 -0500 Subject: [PATCH] Add git worktree remove/rename API (#49135) 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 --- 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(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 2057cd1d85958719255b46d441a31a80be4a95d0..a96a8d78e349efe7e166df4eef0a308fcba69ffe 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/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, pub simulated_index_write_error_message: Option, + pub simulated_create_worktree_error: Option, pub refs: HashMap, pub graph_commits: Vec>, + pub worktrees: Vec, } 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>> { - unimplemented!() + self.with_state_async(false, |state| Ok(state.worktrees.clone())) } fn create_worktree( &self, - _: String, - _: PathBuf, - _: Option, + name: String, + directory: PathBuf, + from_commit: Option, ) -> 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" + ); + } +} diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 75ce789aafd38b9cc2b97e0fc4ad50a9496797e0..d7e631dabe1e1a3dabe18e31d74c555e8d5087a2 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -2069,6 +2069,13 @@ impl FakeFs { .unwrap(); } + pub fn set_create_worktree_error(&self, dot_git: &Path, message: Option) { + self.with_git_state(dot_git, true, |state| { + state.simulated_create_worktree_error = message; + }) + .unwrap(); + } + pub fn paths(&self, include_dot_git: bool) -> Vec { let mut result = Vec::new(); let mut queue = collections::VecDeque::new(); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 304a0f2c95dc8e26eb22368eeb281d235a7f458a..d57b5b583b9228b1bbaba439928a07c5f1a86768 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -203,18 +203,27 @@ impl Worktree { pub fn parse_worktrees_from_str>(raw_worktrees: T) -> Vec { 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, ) -> 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 = 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 = 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<()>> {