Fix usability issues with automatically-created git worktrees (#51775)

Max Brunsfeld and Richard Feldman created

* [x] Display original project name as root folder in project panel,
titlebar
* [x] When manually creating worktrees, ensure final path component is
original project name
* [x] Display original project name, worktree name, and branch name in
titlebar
* [x] Only show main checkout in project switcher

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Richard Feldman <oss@rtfeldman.com>

Change summary

Cargo.lock                                                            |   1 
crates/agent_ui/src/agent_panel.rs                                    |  10 
crates/collab/tests/integration/git_tests.rs                          |   4 
crates/collab/tests/integration/remote_editing_collaboration_tests.rs |   2 
crates/extension/src/extension.rs                                     |   3 
crates/fs/src/fake_git_repo.rs                                        |  13 
crates/fs/src/fs.rs                                                   |   6 
crates/fs/tests/integration/fake_git_repo.rs                          | 223 
crates/git/src/repository.rs                                          | 666 
crates/git_ui/src/worktree_picker.rs                                  |  11 
crates/markdown_preview/Cargo.toml                                    |   1 
crates/markdown_preview/src/markdown_renderer.rs                      |   2 
crates/project/src/git_store.rs                                       | 125 
crates/project/src/project.rs                                         |   1 
crates/project/tests/integration/git_store.rs                         | 155 
crates/recent_projects/src/recent_projects.rs                         |   3 
crates/title_bar/src/title_bar.rs                                     |  68 
crates/workspace/src/persistence.rs                                   | 192 
crates/workspace/src/workspace.rs                                     |   2 
19 files changed, 812 insertions(+), 676 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -10251,7 +10251,6 @@ dependencies = [
  "async-recursion",
  "collections",
  "editor",
- "fs",
  "gpui",
  "html5ever 0.27.0",
  "language",

crates/agent_ui/src/agent_panel.rs 🔗

@@ -60,7 +60,6 @@ use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
 use extension::ExtensionEvents;
 use extension_host::ExtensionStore;
 use fs::Fs;
-use git::repository::validate_worktree_directory;
 use gpui::{
     Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
     DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
@@ -2613,11 +2612,10 @@ impl AgentPanel {
 
         for repo in git_repos {
             let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
-                let original_repo = repo.original_repo_abs_path.clone();
-                let directory =
-                    validate_worktree_directory(&original_repo, worktree_directory_setting)?;
-                let new_path = directory.join(branch_name);
-                let receiver = repo.create_worktree(branch_name.to_string(), directory, None);
+                let new_path =
+                    repo.path_for_new_linked_worktree(branch_name, worktree_directory_setting)?;
+                let receiver =
+                    repo.create_worktree(branch_name.to_string(), new_path.clone(), None);
                 let work_dir = repo.work_directory_abs_path.clone();
                 anyhow::Ok((work_dir, new_path, receiver))
             })?;

crates/collab/tests/integration/git_tests.rs 🔗

@@ -215,7 +215,7 @@ async fn test_remote_git_worktrees(
         repo_b.update(cx, |repository, _| {
             repository.create_worktree(
                 "feature-branch".to_string(),
-                worktree_directory.clone(),
+                worktree_directory.join("feature-branch"),
                 Some("abc123".to_string()),
             )
         })
@@ -266,7 +266,7 @@ async fn test_remote_git_worktrees(
         repo_b.update(cx, |repository, _| {
             repository.create_worktree(
                 "bugfix-branch".to_string(),
-                worktree_directory.clone(),
+                worktree_directory.join("bugfix-branch"),
                 None,
             )
         })

crates/extension/src/extension.rs 🔗

@@ -11,7 +11,6 @@ use std::sync::Arc;
 use ::lsp::LanguageServerName;
 use anyhow::{Context as _, Result, bail};
 use async_trait::async_trait;
-use fs::normalize_path;
 use gpui::{App, Task};
 use language::LanguageName;
 use semver::Version;
@@ -57,7 +56,7 @@ pub trait Extension: Send + Sync + 'static {
 
     /// Returns a path relative to this extension's working directory.
     fn path_from_extension(&self, path: &Path) -> PathBuf {
-        normalize_path(&self.work_dir().join(path))
+        util::normalize_path(&self.work_dir().join(path))
     }
 
     async fn language_server_command(

crates/fs/src/fake_git_repo.rs 🔗

@@ -438,15 +438,14 @@ impl GitRepository for FakeGitRepository {
 
     fn create_worktree(
         &self,
-        name: String,
-        directory: PathBuf,
+        branch_name: String,
+        path: PathBuf,
         from_commit: Option<String>,
     ) -> BoxFuture<'_, Result<()>> {
         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| {
@@ -461,10 +460,10 @@ impl GitRepository for FakeGitRepository {
             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);
+                    if state.branches.contains(&branch_name) {
+                        bail!("a branch named '{}' already exists", branch_name);
                     }
-                    let ref_name = format!("refs/heads/{name}");
+                    let ref_name = format!("refs/heads/{branch_name}");
                     let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string());
                     state.refs.insert(ref_name.clone(), sha.clone());
                     state.worktrees.push(Worktree {
@@ -472,7 +471,7 @@ impl GitRepository for FakeGitRepository {
                         ref_name: ref_name.into(),
                         sha: sha.into(),
                     });
-                    state.branches.insert(name);
+                    state.branches.insert(branch_name);
                     Ok::<(), anyhow::Error>(())
                 }
             })??;

crates/fs/src/fs.rs 🔗

@@ -60,6 +60,8 @@ use git::{
     repository::{InitialGraphCommitData, RepoPath, repo_path},
     status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus},
 };
+#[cfg(feature = "test-support")]
+use util::normalize_path;
 
 #[cfg(feature = "test-support")]
 use smol::io::AsyncReadExt;
@@ -2885,10 +2887,6 @@ impl Fs for FakeFs {
     }
 }
 
-pub fn normalize_path(path: &Path) -> PathBuf {
-    util::normalize_path(path)
-}
-
 pub async fn copy_recursive<'a>(
     fs: &'a dyn Fs,
     source: &'a Path,

crates/fs/tests/integration/fake_git_repo.rs 🔗

@@ -6,139 +6,108 @@ use util::path;
 
 #[gpui::test]
 async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
-    let worktree_dir_settings = &["../worktrees", ".git/zed-worktrees", "my-worktrees/"];
-
-    for worktree_dir_setting in worktree_dir_settings {
-        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 only the main worktree exists
-        let worktrees = repo.worktrees().await.unwrap();
-        assert_eq!(worktrees.len(), 1);
-        assert_eq!(worktrees[0].path, PathBuf::from("/project"));
-
-        let expected_dir = git::repository::resolve_worktree_directory(
-            Path::new("/project"),
-            worktree_dir_setting,
-        );
-
-        // Create a worktree
-        repo.create_worktree(
-            "feature-branch".to_string(),
-            expected_dir.clone(),
-            Some("abc123".to_string()),
-        )
+    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 only the main worktree exists
+    let worktrees = repo.worktrees().await.unwrap();
+    assert_eq!(worktrees.len(), 1);
+    assert_eq!(worktrees[0].path, PathBuf::from("/project"));
+
+    fs.create_dir("/my-worktrees".as_ref()).await.unwrap();
+    let worktrees_dir = Path::new("/my-worktrees");
+
+    // Create a worktree
+    let worktree_1_dir = worktrees_dir.join("feature-branch");
+    repo.create_worktree(
+        "feature-branch".to_string(),
+        worktree_1_dir.clone(),
+        Some("abc123".to_string()),
+    )
+    .await
+    .unwrap();
+
+    // List worktrees — should have main + one created
+    let worktrees = repo.worktrees().await.unwrap();
+    assert_eq!(worktrees.len(), 2);
+    assert_eq!(worktrees[0].path, PathBuf::from("/project"));
+    assert_eq!(worktrees[1].path, worktree_1_dir);
+    assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch");
+    assert_eq!(worktrees[1].sha.as_ref(), "abc123");
+
+    // Directory should exist in FakeFs after create
+    assert!(fs.is_dir(&worktrees_dir.join("feature-branch")).await);
+
+    // 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();
 
-        // List worktrees — should have main + one created
-        let worktrees = repo.worktrees().await.unwrap();
-        assert_eq!(worktrees.len(), 2);
-        assert_eq!(worktrees[0].path, PathBuf::from("/project"));
-        assert_eq!(
-            worktrees[1].path,
-            expected_dir.join("feature-branch"),
-            "failed for worktree_directory setting: {worktree_dir_setting:?}"
-        );
-        assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch");
-        assert_eq!(worktrees[1].sha.as_ref(), "abc123");
-
-        // Directory should exist in FakeFs after create
-        assert!(
-            fs.is_dir(&expected_dir.join("feature-branch")).await,
-            "worktree directory should be created in FakeFs for setting {worktree_dir_setting:?}"
-        );
-
-        // Create a second worktree (without explicit commit)
-        repo.create_worktree("bugfix-branch".to_string(), expected_dir.clone(), None)
-            .await
-            .unwrap();
-
-        let worktrees = repo.worktrees().await.unwrap();
-        assert_eq!(worktrees.len(), 3);
-        assert!(
-            fs.is_dir(&expected_dir.join("bugfix-branch")).await,
-            "second worktree directory should be created in FakeFs for setting {worktree_dir_setting:?}"
-        );
-
-        // Rename the first worktree
-        repo.rename_worktree(
-            expected_dir.join("feature-branch"),
-            expected_dir.join("renamed-branch"),
-        )
+    let worktrees = repo.worktrees().await.unwrap();
+    assert_eq!(worktrees.len(), 3);
+    assert!(fs.is_dir(&worktree_2_dir).await);
+
+    // Rename the first worktree
+    repo.rename_worktree(worktree_1_dir, worktrees_dir.join("renamed-branch"))
         .await
         .unwrap();
 
-        let worktrees = repo.worktrees().await.unwrap();
-        assert_eq!(worktrees.len(), 3);
-        assert!(
-            worktrees
-                .iter()
-                .any(|w| w.path == expected_dir.join("renamed-branch")),
-            "renamed worktree should exist at new path for setting {worktree_dir_setting:?}"
-        );
-        assert!(
-            worktrees
-                .iter()
-                .all(|w| w.path != expected_dir.join("feature-branch")),
-            "old path should no longer exist for setting {worktree_dir_setting:?}"
-        );
-
-        // Directory should be moved in FakeFs after rename
-        assert!(
-            !fs.is_dir(&expected_dir.join("feature-branch")).await,
-            "old worktree directory should not exist after rename for setting {worktree_dir_setting:?}"
-        );
-        assert!(
-            fs.is_dir(&expected_dir.join("renamed-branch")).await,
-            "new worktree directory should exist after rename for setting {worktree_dir_setting:?}"
-        );
-
-        // 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(expected_dir.join("renamed-branch"), false)
-            .await
-            .unwrap();
-
-        let worktrees = repo.worktrees().await.unwrap();
-        assert_eq!(worktrees.len(), 2);
-        assert_eq!(worktrees[0].path, PathBuf::from("/project"));
-        assert_eq!(worktrees[1].path, expected_dir.join("bugfix-branch"));
-
-        // Directory should be removed from FakeFs after remove
-        assert!(
-            !fs.is_dir(&expected_dir.join("renamed-branch")).await,
-            "worktree directory should be removed from FakeFs for setting {worktree_dir_setting:?}"
-        );
-
-        // 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(expected_dir.join("bugfix-branch"), false)
-            .await
-            .unwrap();
-
-        let worktrees = repo.worktrees().await.unwrap();
-        assert_eq!(worktrees.len(), 1);
-        assert_eq!(worktrees[0].path, PathBuf::from("/project"));
-        assert!(
-            !fs.is_dir(&expected_dir.join("bugfix-branch")).await,
-            "last worktree directory should be removed from FakeFs for setting {worktree_dir_setting:?}"
-        );
-    }
+    let worktrees = repo.worktrees().await.unwrap();
+    assert_eq!(worktrees.len(), 3);
+    assert!(
+        worktrees
+            .iter()
+            .any(|w| w.path == worktrees_dir.join("renamed-branch")),
+    );
+    assert!(
+        worktrees
+            .iter()
+            .all(|w| w.path != worktrees_dir.join("feature-branch")),
+    );
+
+    // Directory should be moved in FakeFs after rename
+    assert!(!fs.is_dir(&worktrees_dir.join("feature-branch")).await);
+    assert!(fs.is_dir(&worktrees_dir.join("renamed-branch")).await);
+
+    // 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(worktrees_dir.join("renamed-branch"), false)
+        .await
+        .unwrap();
+
+    let worktrees = repo.worktrees().await.unwrap();
+    assert_eq!(worktrees.len(), 2);
+    assert_eq!(worktrees[0].path, PathBuf::from("/project"));
+    assert_eq!(worktrees[1].path, worktree_2_dir);
+
+    // Directory should be removed from FakeFs after remove
+    assert!(!fs.is_dir(&worktrees_dir.join("renamed-branch")).await);
+
+    // 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(worktree_2_dir.clone(), false)
+        .await
+        .unwrap();
+
+    let worktrees = repo.worktrees().await.unwrap();
+    assert_eq!(worktrees.len(), 1);
+    assert_eq!(worktrees[0].path, PathBuf::from("/project"));
+    assert!(!fs.is_dir(&worktree_2_dir).await);
 }
 
 #[gpui::test]

crates/git/src/repository.rs 🔗

@@ -36,7 +36,7 @@ use thiserror::Error;
 use util::command::{Stdio, new_command};
 use util::paths::PathStyle;
 use util::rel_path::RelPath;
-use util::{ResultExt, normalize_path, paths};
+use util::{ResultExt, paths};
 use uuid::Uuid;
 
 pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
@@ -76,97 +76,6 @@ pub fn original_repo_path_from_common_dir(common_dir: &Path) -> PathBuf {
     }
 }
 
-/// Resolves the configured worktree directory to an absolute path.
-///
-/// `worktree_directory_setting` is the raw string from the user setting
-/// (e.g. `"../worktrees"`, `".git/zed-worktrees"`, `"my-worktrees/"`).
-/// Trailing slashes are stripped. The path is resolved relative to
-/// `working_directory` (the repository's working directory root).
-///
-/// When the resolved directory falls outside the working directory
-/// (e.g. `"../worktrees"`), the repository's directory name is
-/// automatically appended so that sibling repos don't collide.
-/// For example, with working directory `~/code/zed` and setting
-/// `"../worktrees"`, this returns `~/code/worktrees/zed`.
-///
-/// When the resolved directory is inside the working directory
-/// (e.g. `".git/zed-worktrees"`), no extra component is added
-/// because the path is already project-scoped.
-pub fn resolve_worktree_directory(
-    working_directory: &Path,
-    worktree_directory_setting: &str,
-) -> PathBuf {
-    let trimmed = worktree_directory_setting.trim_end_matches(['/', '\\']);
-    let joined = working_directory.join(trimmed);
-    let resolved = normalize_path(&joined);
-
-    if resolved.starts_with(working_directory) {
-        resolved
-    } else if let Some(repo_dir_name) = working_directory.file_name() {
-        resolved.join(repo_dir_name)
-    } else {
-        resolved
-    }
-}
-
-/// Validates that the resolved worktree directory is acceptable:
-/// - The setting must not be an absolute path.
-/// - The resolved path must be either a subdirectory of the working
-///   directory or a subdirectory of its parent (i.e., a sibling).
-///
-/// Returns `Ok(resolved_path)` or an error with a user-facing message.
-pub fn validate_worktree_directory(
-    working_directory: &Path,
-    worktree_directory_setting: &str,
-) -> Result<PathBuf> {
-    // Check the original setting before trimming, since a path like "///"
-    // is absolute but becomes "" after stripping trailing separators.
-    // Also check for leading `/` or `\` explicitly, because on Windows
-    // `Path::is_absolute()` requires a drive letter — so `/tmp/worktrees`
-    // would slip through even though it's clearly not a relative path.
-    if Path::new(worktree_directory_setting).is_absolute()
-        || worktree_directory_setting.starts_with('/')
-        || worktree_directory_setting.starts_with('\\')
-    {
-        anyhow::bail!(
-            "git.worktree_directory must be a relative path, got: {worktree_directory_setting:?}"
-        );
-    }
-
-    if worktree_directory_setting.is_empty() {
-        anyhow::bail!("git.worktree_directory must not be empty");
-    }
-
-    let trimmed = worktree_directory_setting.trim_end_matches(['/', '\\']);
-    if trimmed == ".." {
-        anyhow::bail!("git.worktree_directory must not be \"..\" (use \"../some-name\" instead)");
-    }
-
-    let resolved = resolve_worktree_directory(working_directory, worktree_directory_setting);
-
-    let parent = working_directory.parent().unwrap_or(working_directory);
-
-    if !resolved.starts_with(parent) {
-        anyhow::bail!(
-            "git.worktree_directory resolved to {resolved:?}, which is outside \
-             the project root and its parent directory. It must resolve to a \
-             subdirectory of {working_directory:?} or a sibling of it."
-        );
-    }
-
-    Ok(resolved)
-}
-
-/// Returns the full absolute path for a specific branch's worktree
-/// given the resolved worktree directory.
-pub fn worktree_path_for_branch(
-    working_directory: &Path,
-    worktree_directory_setting: &str,
-    branch: &str,
-) -> PathBuf {
-    resolve_worktree_directory(working_directory, worktree_directory_setting).join(branch)
-}
-
 /// Commit data needed for the git graph visualization.
 #[derive(Debug, Clone)]
 pub struct GraphCommitData {
@@ -769,8 +678,8 @@ pub trait GitRepository: Send + Sync {
 
     fn create_worktree(
         &self,
-        name: String,
-        directory: PathBuf,
+        branch_name: String,
+        path: PathBuf,
         from_commit: Option<String>,
     ) -> BoxFuture<'_, Result<()>>;
 
@@ -1712,20 +1621,19 @@ impl GitRepository for RealGitRepository {
 
     fn create_worktree(
         &self,
-        name: String,
-        directory: PathBuf,
+        branch_name: String,
+        path: PathBuf,
         from_commit: Option<String>,
     ) -> BoxFuture<'_, Result<()>> {
         let git_binary = self.git_binary();
-        let final_path = directory.join(&name);
         let mut args = vec![
             OsString::from("--no-optional-locks"),
             OsString::from("worktree"),
             OsString::from("add"),
             OsString::from("-b"),
-            OsString::from(name.as_str()),
+            OsString::from(branch_name.as_str()),
             OsString::from("--"),
-            OsString::from(final_path.as_os_str()),
+            OsString::from(path.as_os_str()),
         ];
         if let Some(from_commit) = from_commit {
             args.push(OsString::from(from_commit));
@@ -1735,7 +1643,7 @@ impl GitRepository for RealGitRepository {
 
         self.executor
             .spawn(async move {
-                std::fs::create_dir_all(final_path.parent().unwrap_or(&final_path))?;
+                std::fs::create_dir_all(path.parent().unwrap_or(&path))?;
                 let git = git_binary?;
                 let output = git.build_command(&args).output().await?;
                 if output.status.success() {
@@ -3390,6 +3298,8 @@ fn checkpoint_author_envs() -> HashMap<String, String> {
 
 #[cfg(test)]
 mod tests {
+    use std::fs;
+
     use super::*;
     use gpui::TestAppContext;
 
@@ -3912,86 +3822,76 @@ mod tests {
         assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
     }
 
-    const TEST_WORKTREE_DIRECTORIES: &[&str] =
-        &["../worktrees", ".git/zed-worktrees", "my-worktrees/"];
-
     #[gpui::test]
     async fn test_create_and_list_worktrees(cx: &mut TestAppContext) {
         disable_git_global_config();
         cx.executor().allow_parking();
 
-        for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES {
-            let repo_dir = tempfile::tempdir().unwrap();
-            git2::Repository::init(repo_dir.path()).unwrap();
+        let temp_dir = tempfile::tempdir().unwrap();
+        let repo_dir = temp_dir.path().join("repo");
+        let worktrees_dir = temp_dir.path().join("worktrees");
 
-            let repo = RealGitRepository::new(
-                &repo_dir.path().join(".git"),
-                None,
-                Some("git".into()),
-                cx.executor(),
-            )
-            .unwrap();
+        fs::create_dir_all(&repo_dir).unwrap();
+        fs::create_dir_all(&worktrees_dir).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();
+        git2::Repository::init(&repo_dir).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()
-            );
+        let repo = RealGitRepository::new(
+            &repo_dir.join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
 
-            // Create a new worktree
-            repo.create_worktree(
-                "test-branch".to_string(),
-                resolve_worktree_directory(repo_dir.path(), worktree_dir_setting),
-                Some("HEAD".to_string()),
-            )
+        // Create an initial commit (required for worktrees)
+        smol::fs::write(repo_dir.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 two
-            let worktrees = repo.worktrees().await.unwrap();
-            assert_eq!(worktrees.len(), 2);
-
-            let expected_path =
-                worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "test-branch");
-            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(),
-                expected_path.canonicalize().unwrap(),
-                "failed for worktree_directory setting: {worktree_dir_setting:?}"
-            );
+        // 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.canonicalize().unwrap()
+        );
 
-            // Clean up so the next iteration starts fresh
-            repo.remove_worktree(expected_path, true).await.unwrap();
+        let worktree_path = worktrees_dir.join("some-worktree");
 
-            // Clean up the worktree base directory if it was created outside repo_dir
-            // (e.g. for the "../worktrees" setting, it won't be inside the TempDir)
-            let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting);
-            if !resolved_dir.starts_with(repo_dir.path()) {
-                let _ = std::fs::remove_dir_all(&resolved_dir);
-            }
-        }
+        // Create a new worktree
+        repo.create_worktree(
+            "test-branch".to_string(),
+            worktree_path.clone(),
+            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_path.canonicalize().unwrap(),
+        );
     }
 
     #[gpui::test]
@@ -3999,147 +3899,92 @@ mod tests {
         disable_git_global_config();
         cx.executor().allow_parking();
 
-        for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES {
-            let repo_dir = tempfile::tempdir().unwrap();
-            git2::Repository::init(repo_dir.path()).unwrap();
+        let temp_dir = tempfile::tempdir().unwrap();
+        let repo_dir = temp_dir.path().join("repo");
+        let worktrees_dir = temp_dir.path().join("worktrees");
+        git2::Repository::init(&repo_dir).unwrap();
 
-            let repo = RealGitRepository::new(
-                &repo_dir.path().join(".git"),
-                None,
-                Some("git".into()),
-                cx.executor(),
-            )
-            .unwrap();
+        let repo = RealGitRepository::new(
+            &repo_dir.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()),
-            )
+        // Create an initial commit
+        smol::fs::write(repo_dir.join("file.txt"), "content")
             .await
             .unwrap();
-
-            // Create a worktree
-            repo.create_worktree(
-                "to-remove".to_string(),
-                resolve_worktree_directory(repo_dir.path(), worktree_dir_setting),
-                Some("HEAD".to_string()),
-            )
+        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();
 
-            let worktree_path =
-                worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "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());
-
-            // Clean up the worktree base directory if it was created outside repo_dir
-            // (e.g. for the "../worktrees" setting, it won't be inside the TempDir)
-            let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting);
-            if !resolved_dir.starts_with(repo_dir.path()) {
-                let _ = std::fs::remove_dir_all(&resolved_dir);
-            }
-        }
-    }
+        // Create a worktree
+        let worktree_path = worktrees_dir.join("worktree-to-remove");
+        repo.create_worktree(
+            "to-remove".to_string(),
+            worktree_path.clone(),
+            Some("HEAD".to_string()),
+        )
+        .await
+        .unwrap();
 
-    #[gpui::test]
-    async fn test_remove_worktree_force(cx: &mut TestAppContext) {
-        disable_git_global_config();
-        cx.executor().allow_parking();
+        // Remove the worktree
+        repo.remove_worktree(worktree_path.clone(), false)
+            .await
+            .unwrap();
 
-        for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES {
-            let repo_dir = tempfile::tempdir().unwrap();
-            git2::Repository::init(repo_dir.path()).unwrap();
+        // Verify the directory is removed
+        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"
+        );
+        assert!(!worktree_path.exists());
+
+        // Create a worktree
+        let worktree_path = worktrees_dir.join("dirty-wt");
+        repo.create_worktree(
+            "dirty-wt".to_string(),
+            worktree_path.clone(),
+            Some("HEAD".to_string()),
+        )
+        .await
+        .unwrap();
 
-            let repo = RealGitRepository::new(
-                &repo_dir.path().join(".git"),
-                None,
-                Some("git".into()),
-                cx.executor(),
-            )
-            .unwrap();
+        assert!(worktree_path.exists());
 
-            // 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()),
-            )
+        // Add uncommitted changes in the worktree
+        smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
             .await
             .unwrap();
 
-            // Create a worktree
-            repo.create_worktree(
-                "dirty-wt".to_string(),
-                resolve_worktree_directory(repo_dir.path(), worktree_dir_setting),
-                Some("HEAD".to_string()),
-            )
+        // 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 worktree_path =
-                worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "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());
-
-            // Clean up the worktree base directory if it was created outside repo_dir
-            // (e.g. for the "../worktrees" setting, it won't be inside the TempDir)
-            let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting);
-            if !resolved_dir.starts_with(repo_dir.path()) {
-                let _ = std::fs::remove_dir_all(&resolved_dir);
-            }
-        }
+        let worktrees = repo.worktrees().await.unwrap();
+        assert_eq!(worktrees.len(), 1);
+        assert!(!worktree_path.exists());
     }
 
     #[gpui::test]
@@ -4147,141 +3992,69 @@ mod tests {
         disable_git_global_config();
         cx.executor().allow_parking();
 
-        for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES {
-            let repo_dir = tempfile::tempdir().unwrap();
-            git2::Repository::init(repo_dir.path()).unwrap();
+        let temp_dir = tempfile::tempdir().unwrap();
+        let repo_dir = temp_dir.path().join("repo");
+        let worktrees_dir = temp_dir.path().join("worktrees");
 
-            let repo = RealGitRepository::new(
-                &repo_dir.path().join(".git"),
-                None,
-                Some("git".into()),
-                cx.executor(),
-            )
-            .unwrap();
+        git2::Repository::init(&repo_dir).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()),
-            )
+        let repo = RealGitRepository::new(
+            &repo_dir.join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+
+        // Create an initial commit
+        smol::fs::write(repo_dir.join("file.txt"), "content")
             .await
             .unwrap();
-
-            // Create a worktree
-            repo.create_worktree(
-                "old-name".to_string(),
-                resolve_worktree_directory(repo_dir.path(), worktree_dir_setting),
-                Some("HEAD".to_string()),
-            )
+        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();
 
-            let old_path =
-                worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "old-name");
-            assert!(old_path.exists());
-
-            // Move the worktree to a new path
-            let new_path =
-                resolve_worktree_directory(repo_dir.path(), worktree_dir_setting).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()
-            );
-
-            // Clean up so the next iteration starts fresh
-            repo.remove_worktree(new_path, true).await.unwrap();
-
-            // Clean up the worktree base directory if it was created outside repo_dir
-            // (e.g. for the "../worktrees" setting, it won't be inside the TempDir)
-            let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting);
-            if !resolved_dir.starts_with(repo_dir.path()) {
-                let _ = std::fs::remove_dir_all(&resolved_dir);
-            }
-        }
-    }
-
-    #[test]
-    fn test_resolve_worktree_directory() {
-        let work_dir = Path::new("/code/my-project");
-
-        // Sibling directory — outside project, so repo dir name is appended
-        assert_eq!(
-            resolve_worktree_directory(work_dir, "../worktrees"),
-            PathBuf::from("/code/worktrees/my-project")
-        );
-
-        // Git subdir — inside project, no repo name appended
-        assert_eq!(
-            resolve_worktree_directory(work_dir, ".git/zed-worktrees"),
-            PathBuf::from("/code/my-project/.git/zed-worktrees")
-        );
-
-        // Simple subdir — inside project, no repo name appended
-        assert_eq!(
-            resolve_worktree_directory(work_dir, "my-worktrees"),
-            PathBuf::from("/code/my-project/my-worktrees")
-        );
-
-        // Trailing slash is stripped
-        assert_eq!(
-            resolve_worktree_directory(work_dir, "../worktrees/"),
-            PathBuf::from("/code/worktrees/my-project")
-        );
-        assert_eq!(
-            resolve_worktree_directory(work_dir, "my-worktrees/"),
-            PathBuf::from("/code/my-project/my-worktrees")
-        );
-
-        // Multiple trailing slashes
-        assert_eq!(
-            resolve_worktree_directory(work_dir, "foo///"),
-            PathBuf::from("/code/my-project/foo")
-        );
+        // Create a worktree
+        let old_path = worktrees_dir.join("old-worktree-name");
+        repo.create_worktree(
+            "old-name".to_string(),
+            old_path.clone(),
+            Some("HEAD".to_string()),
+        )
+        .await
+        .unwrap();
 
-        // Trailing backslashes (Windows-style)
-        assert_eq!(
-            resolve_worktree_directory(work_dir, "my-worktrees\\"),
-            PathBuf::from("/code/my-project/my-worktrees")
-        );
-        assert_eq!(
-            resolve_worktree_directory(work_dir, "foo\\/\\"),
-            PathBuf::from("/code/my-project/foo")
-        );
+        assert!(old_path.exists());
 
-        // Empty string resolves to the working directory itself (inside)
-        assert_eq!(
-            resolve_worktree_directory(work_dir, ""),
-            PathBuf::from("/code/my-project")
-        );
+        // Move the worktree to a new path
+        let new_path = worktrees_dir.join("new-worktree-name");
+        repo.rename_worktree(old_path.clone(), new_path.clone())
+            .await
+            .unwrap();
 
-        // Just ".." — outside project, repo dir name appended
+        // 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!(
-            resolve_worktree_directory(work_dir, ".."),
-            PathBuf::from("/code/my-project")
+            moved_worktree.path.canonicalize().unwrap(),
+            new_path.canonicalize().unwrap()
         );
     }
 
@@ -4313,71 +4086,6 @@ mod tests {
         );
     }
 
-    #[test]
-    fn test_validate_worktree_directory() {
-        let work_dir = Path::new("/code/my-project");
-
-        // Valid: sibling
-        assert!(validate_worktree_directory(work_dir, "../worktrees").is_ok());
-
-        // Valid: subdirectory
-        assert!(validate_worktree_directory(work_dir, ".git/zed-worktrees").is_ok());
-        assert!(validate_worktree_directory(work_dir, "my-worktrees").is_ok());
-
-        // Invalid: just ".." would resolve back to the working directory itself
-        let err = validate_worktree_directory(work_dir, "..").unwrap_err();
-        assert!(err.to_string().contains("must not be \"..\""));
-
-        // Invalid: ".." with trailing separators
-        let err = validate_worktree_directory(work_dir, "..\\").unwrap_err();
-        assert!(err.to_string().contains("must not be \"..\""));
-        let err = validate_worktree_directory(work_dir, "../").unwrap_err();
-        assert!(err.to_string().contains("must not be \"..\""));
-
-        // Invalid: empty string would resolve to the working directory itself
-        let err = validate_worktree_directory(work_dir, "").unwrap_err();
-        assert!(err.to_string().contains("must not be empty"));
-
-        // Invalid: absolute path
-        let err = validate_worktree_directory(work_dir, "/tmp/worktrees").unwrap_err();
-        assert!(err.to_string().contains("relative path"));
-
-        // Invalid: "/" is absolute on Unix
-        let err = validate_worktree_directory(work_dir, "/").unwrap_err();
-        assert!(err.to_string().contains("relative path"));
-
-        // Invalid: "///" is absolute
-        let err = validate_worktree_directory(work_dir, "///").unwrap_err();
-        assert!(err.to_string().contains("relative path"));
-
-        // Invalid: escapes too far up
-        let err = validate_worktree_directory(work_dir, "../../other-project/wt").unwrap_err();
-        assert!(err.to_string().contains("outside"));
-    }
-
-    #[test]
-    fn test_worktree_path_for_branch() {
-        let work_dir = Path::new("/code/my-project");
-
-        // Outside project — repo dir name is part of the resolved directory
-        assert_eq!(
-            worktree_path_for_branch(work_dir, "../worktrees", "feature/foo"),
-            PathBuf::from("/code/worktrees/my-project/feature/foo")
-        );
-
-        // Inside project — no repo dir name inserted
-        assert_eq!(
-            worktree_path_for_branch(work_dir, ".git/zed-worktrees", "my-branch"),
-            PathBuf::from("/code/my-project/.git/zed-worktrees/my-branch")
-        );
-
-        // Trailing slash on setting (inside project)
-        assert_eq!(
-            worktree_path_for_branch(work_dir, "my-worktrees/", "branch"),
-            PathBuf::from("/code/my-project/my-worktrees/branch")
-        );
-    }
-
     impl RealGitRepository {
         /// Force a Git garbage collection on the repository.
         fn gc(&self) -> BoxFuture<'_, Result<()>> {

crates/git_ui/src/worktree_picker.rs 🔗

@@ -2,7 +2,7 @@ use anyhow::Context as _;
 use collections::HashSet;
 use fuzzy::StringMatchCandidate;
 
-use git::repository::{Worktree as GitWorktree, validate_worktree_directory};
+use git::repository::Worktree as GitWorktree;
 use gpui::{
     Action, App, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
     Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
@@ -300,11 +300,10 @@ impl WorktreeListDelegate {
                     .git
                     .worktree_directory
                     .clone();
-                let original_repo = repo.original_repo_abs_path.clone();
-                let directory =
-                    validate_worktree_directory(&original_repo, &worktree_directory_setting)?;
-                let new_worktree_path = directory.join(&branch);
-                let receiver = repo.create_worktree(branch.clone(), directory, commit);
+                let new_worktree_path =
+                    repo.path_for_new_linked_worktree(&branch, &worktree_directory_setting)?;
+                let receiver =
+                    repo.create_worktree(branch.clone(), new_worktree_path.clone(), commit);
                 anyhow::Ok((receiver, new_worktree_path))
             })?;
             receiver.await??;

crates/markdown_preview/Cargo.toml 🔗

@@ -19,7 +19,6 @@ anyhow.workspace = true
 async-recursion.workspace = true
 collections.workspace = true
 editor.workspace = true
-fs.workspace = true
 gpui.workspace = true
 html5ever.workspace = true
 language.workspace = true

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -9,7 +9,6 @@ use crate::{
     markdown_preview_view::MarkdownPreviewView,
 };
 use collections::HashMap;
-use fs::normalize_path;
 use gpui::{
     AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div,
     Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
@@ -25,6 +24,7 @@ use std::{
 };
 use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
 use ui::{CopyButton, LinkPreview, ToggleState, prelude::*, tooltip_container};
+use util::normalize_path;
 use workspace::{OpenOptions, OpenVisible, Workspace};
 
 pub struct CheckboxClickedEvent {

crates/project/src/git_store.rs 🔗

@@ -5755,6 +5755,31 @@ impl Repository {
         })
     }
 
+    /// If this is a linked worktree (*NOT* the main checkout of a repository),
+    /// returns the pathed for the linked worktree.
+    ///
+    /// Returns None if this is the main checkout.
+    pub fn linked_worktree_path(&self) -> Option<&Arc<Path>> {
+        if self.work_directory_abs_path != self.original_repo_abs_path {
+            Some(&self.work_directory_abs_path)
+        } else {
+            None
+        }
+    }
+
+    pub fn path_for_new_linked_worktree(
+        &self,
+        branch_name: &str,
+        worktree_directory_setting: &str,
+    ) -> Result<PathBuf> {
+        let original_repo = self.original_repo_abs_path.clone();
+        let project_name = original_repo
+            .file_name()
+            .ok_or_else(|| anyhow!("git repo must have a directory name"))?;
+        let directory = worktrees_directory_for_repo(&original_repo, worktree_directory_setting)?;
+        Ok(directory.join(branch_name).join(project_name))
+    }
+
     pub fn worktrees(&mut self) -> oneshot::Receiver<Result<Vec<GitWorktree>>> {
         let id = self.id;
         self.send_job(None, move |repo, _| async move {
@@ -5784,25 +5809,25 @@ impl Repository {
 
     pub fn create_worktree(
         &mut self,
-        name: String,
-        directory: PathBuf,
+        branch_name: String,
+        path: PathBuf,
         commit: Option<String>,
     ) -> oneshot::Receiver<Result<()>> {
         let id = self.id;
         self.send_job(
-            Some("git worktree add".into()),
+            Some(format!("git worktree add: {}", branch_name).into()),
             move |repo, _cx| async move {
                 match repo {
                     RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
-                        backend.create_worktree(name, directory, commit).await
+                        backend.create_worktree(branch_name, path, commit).await
                     }
                     RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
                         client
                             .request(proto::GitCreateWorktree {
                                 project_id: project_id.0,
                                 repository_id: id.to_proto(),
-                                name,
-                                directory: directory.to_string_lossy().to_string(),
+                                name: branch_name,
+                                directory: path.to_string_lossy().to_string(),
                                 commit,
                             })
                             .await?;
@@ -6716,6 +6741,94 @@ impl Repository {
     }
 }
 
+/// If `path` is a git linked worktree checkout, resolves it to the main
+/// repository's working directory path. Returns `None` if `path` is a normal
+/// repository, not a git repo, or if resolution fails.
+///
+/// Resolution works by:
+/// 1. Reading the `.git` file to get the `gitdir:` pointer
+/// 2. Following that to the worktree-specific git directory
+/// 3. Reading the `commondir` file to find the shared `.git` directory
+/// 4. Deriving the main repo's working directory from the common dir
+pub async fn resolve_git_worktree_to_main_repo(fs: &dyn Fs, path: &Path) -> Option<PathBuf> {
+    let dot_git = path.join(".git");
+    let metadata = fs.metadata(&dot_git).await.ok()??;
+    if metadata.is_dir {
+        return None; // Normal repo, not a linked worktree
+    }
+    // It's a .git file — parse the gitdir: pointer
+    let content = fs.load(&dot_git).await.ok()?;
+    let gitdir_rel = content.strip_prefix("gitdir:")?.trim();
+    let gitdir_abs = fs.canonicalize(&path.join(gitdir_rel)).await.ok()?;
+    // Read commondir to find the main .git directory
+    let commondir_content = fs.load(&gitdir_abs.join("commondir")).await.ok()?;
+    let common_dir = fs
+        .canonicalize(&gitdir_abs.join(commondir_content.trim()))
+        .await
+        .ok()?;
+    Some(git::repository::original_repo_path_from_common_dir(
+        &common_dir,
+    ))
+}
+
+/// Validates that the resolved worktree directory is acceptable:
+/// - The setting must not be an absolute path.
+/// - The resolved path must be either a subdirectory of the working
+///   directory or a subdirectory of its parent (i.e., a sibling).
+///
+/// Returns `Ok(resolved_path)` or an error with a user-facing message.
+pub fn worktrees_directory_for_repo(
+    original_repo_abs_path: &Path,
+    worktree_directory_setting: &str,
+) -> Result<PathBuf> {
+    // Check the original setting before trimming, since a path like "///"
+    // is absolute but becomes "" after stripping trailing separators.
+    // Also check for leading `/` or `\` explicitly, because on Windows
+    // `Path::is_absolute()` requires a drive letter — so `/tmp/worktrees`
+    // would slip through even though it's clearly not a relative path.
+    if Path::new(worktree_directory_setting).is_absolute()
+        || worktree_directory_setting.starts_with('/')
+        || worktree_directory_setting.starts_with('\\')
+    {
+        anyhow::bail!(
+            "git.worktree_directory must be a relative path, got: {worktree_directory_setting:?}"
+        );
+    }
+
+    if worktree_directory_setting.is_empty() {
+        anyhow::bail!("git.worktree_directory must not be empty");
+    }
+
+    let trimmed = worktree_directory_setting.trim_end_matches(['/', '\\']);
+    if trimmed == ".." {
+        anyhow::bail!("git.worktree_directory must not be \"..\" (use \"../some-name\" instead)");
+    }
+
+    let joined = original_repo_abs_path.join(trimmed);
+    let resolved = util::normalize_path(&joined);
+    let resolved = if resolved.starts_with(original_repo_abs_path) {
+        resolved
+    } else if let Some(repo_dir_name) = original_repo_abs_path.file_name() {
+        resolved.join(repo_dir_name)
+    } else {
+        resolved
+    };
+
+    let parent = original_repo_abs_path
+        .parent()
+        .unwrap_or(original_repo_abs_path);
+
+    if !resolved.starts_with(parent) {
+        anyhow::bail!(
+            "git.worktree_directory resolved to {resolved:?}, which is outside \
+             the project root and its parent directory. It must resolve to a \
+             subdirectory of {original_repo_abs_path:?} or a sibling of it."
+        );
+    }
+
+    Ok(resolved)
+}
+
 fn get_permalink_in_rust_registry_src(
     provider_registry: Arc<GitHostingProviderRegistry>,
     path: PathBuf,

crates/project/src/project.rs 🔗

@@ -47,6 +47,7 @@ pub use agent_server_store::{AgentId, AgentServerStore, AgentServersUpdated, Ext
 pub use git_store::{
     ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate,
     git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal},
+    worktrees_directory_for_repo,
 };
 pub use manifest_tree::ManifestTree;
 pub use project_search::{Search, SearchResults};

crates/project/tests/integration/git_store.rs 🔗

@@ -1176,14 +1176,13 @@ mod git_traversal {
 }
 
 mod git_worktrees {
-    use std::path::PathBuf;
-
     use fs::FakeFs;
     use gpui::TestAppContext;
+    use project::worktrees_directory_for_repo;
     use serde_json::json;
     use settings::SettingsStore;
+    use std::path::{Path, PathBuf};
     use util::path;
-
     fn init_test(cx: &mut gpui::TestAppContext) {
         zlog::init_test();
 
@@ -1193,6 +1192,48 @@ mod git_worktrees {
         });
     }
 
+    #[test]
+    fn test_validate_worktree_directory() {
+        let work_dir = Path::new("/code/my-project");
+
+        // Valid: sibling
+        assert!(worktrees_directory_for_repo(work_dir, "../worktrees").is_ok());
+
+        // Valid: subdirectory
+        assert!(worktrees_directory_for_repo(work_dir, ".git/zed-worktrees").is_ok());
+        assert!(worktrees_directory_for_repo(work_dir, "my-worktrees").is_ok());
+
+        // Invalid: just ".." would resolve back to the working directory itself
+        let err = worktrees_directory_for_repo(work_dir, "..").unwrap_err();
+        assert!(err.to_string().contains("must not be \"..\""));
+
+        // Invalid: ".." with trailing separators
+        let err = worktrees_directory_for_repo(work_dir, "..\\").unwrap_err();
+        assert!(err.to_string().contains("must not be \"..\""));
+        let err = worktrees_directory_for_repo(work_dir, "../").unwrap_err();
+        assert!(err.to_string().contains("must not be \"..\""));
+
+        // Invalid: empty string would resolve to the working directory itself
+        let err = worktrees_directory_for_repo(work_dir, "").unwrap_err();
+        assert!(err.to_string().contains("must not be empty"));
+
+        // Invalid: absolute path
+        let err = worktrees_directory_for_repo(work_dir, "/tmp/worktrees").unwrap_err();
+        assert!(err.to_string().contains("relative path"));
+
+        // Invalid: "/" is absolute on Unix
+        let err = worktrees_directory_for_repo(work_dir, "/").unwrap_err();
+        assert!(err.to_string().contains("relative path"));
+
+        // Invalid: "///" is absolute
+        let err = worktrees_directory_for_repo(work_dir, "///").unwrap_err();
+        assert!(err.to_string().contains("relative path"));
+
+        // Invalid: escapes too far up
+        let err = worktrees_directory_for_repo(work_dir, "../../other-project/wt").unwrap_err();
+        assert!(err.to_string().contains("outside"));
+    }
+
     #[gpui::test]
     async fn test_git_worktrees_list_and_create(cx: &mut TestAppContext) {
         init_test(cx);
@@ -1221,12 +1262,13 @@ mod git_worktrees {
         assert_eq!(worktrees.len(), 1);
         assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
 
-        let worktree_directory = PathBuf::from(path!("/root"));
+        let worktrees_directory = PathBuf::from(path!("/root"));
+        let worktree_1_directory = worktrees_directory.join("feature-branch");
         cx.update(|cx| {
             repository.update(cx, |repository, _| {
                 repository.create_worktree(
                     "feature-branch".to_string(),
-                    worktree_directory.clone(),
+                    worktree_1_directory.clone(),
                     Some("abc123".to_string()),
                 )
             })
@@ -1244,15 +1286,16 @@ mod git_worktrees {
             .unwrap();
         assert_eq!(worktrees.len(), 2);
         assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
-        assert_eq!(worktrees[1].path, worktree_directory.join("feature-branch"));
+        assert_eq!(worktrees[1].path, worktree_1_directory);
         assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch");
         assert_eq!(worktrees[1].sha.as_ref(), "abc123");
 
+        let worktree_2_directory = worktrees_directory.join("bugfix-branch");
         cx.update(|cx| {
             repository.update(cx, |repository, _| {
                 repository.create_worktree(
                     "bugfix-branch".to_string(),
-                    worktree_directory.clone(),
+                    worktree_2_directory.clone(),
                     None,
                 )
             })
@@ -1271,24 +1314,18 @@ mod git_worktrees {
             .unwrap();
         assert_eq!(worktrees.len(), 3);
 
-        let feature_worktree = worktrees
+        let worktree_1 = worktrees
             .iter()
             .find(|worktree| worktree.ref_name.as_ref() == "refs/heads/feature-branch")
             .expect("should find feature-branch worktree");
-        assert_eq!(
-            feature_worktree.path,
-            worktree_directory.join("feature-branch")
-        );
+        assert_eq!(worktree_1.path, worktree_1_directory);
 
-        let bugfix_worktree = worktrees
+        let worktree_2 = worktrees
             .iter()
             .find(|worktree| worktree.ref_name.as_ref() == "refs/heads/bugfix-branch")
             .expect("should find bugfix-branch worktree");
-        assert_eq!(
-            bugfix_worktree.path,
-            worktree_directory.join("bugfix-branch")
-        );
-        assert_eq!(bugfix_worktree.sha.as_ref(), "fake-sha");
+        assert_eq!(worktree_2.path, worktree_2_directory);
+        assert_eq!(worktree_2.sha.as_ref(), "fake-sha");
     }
 
     use crate::Project;
@@ -1498,3 +1535,85 @@ mod trust_tests {
         });
     }
 }
+
+mod resolve_worktree_tests {
+    use fs::FakeFs;
+    use gpui::TestAppContext;
+    use project::git_store::resolve_git_worktree_to_main_repo;
+    use serde_json::json;
+    use std::path::{Path, PathBuf};
+
+    #[gpui::test]
+    async fn test_resolve_git_worktree_to_main_repo(cx: &mut TestAppContext) {
+        let fs = FakeFs::new(cx.executor());
+        // Set up a main repo with a worktree entry
+        fs.insert_tree(
+            "/main-repo",
+            json!({
+                ".git": {
+                    "worktrees": {
+                        "feature": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/feature"
+                        }
+                    }
+                },
+                "src": { "main.rs": "" }
+            }),
+        )
+        .await;
+        // Set up a worktree checkout pointing back to the main repo
+        fs.insert_tree(
+            "/worktree-checkout",
+            json!({
+                ".git": "gitdir: /main-repo/.git/worktrees/feature",
+                "src": { "main.rs": "" }
+            }),
+        )
+        .await;
+
+        let result =
+            resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/worktree-checkout")).await;
+        assert_eq!(result, Some(PathBuf::from("/main-repo")));
+    }
+
+    #[gpui::test]
+    async fn test_resolve_git_worktree_normal_repo_returns_none(cx: &mut TestAppContext) {
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/repo",
+            json!({
+                ".git": {},
+                "src": { "main.rs": "" }
+            }),
+        )
+        .await;
+
+        let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/repo")).await;
+        assert_eq!(result, None);
+    }
+
+    #[gpui::test]
+    async fn test_resolve_git_worktree_no_git_returns_none(cx: &mut TestAppContext) {
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/plain",
+            json!({
+                "src": { "main.rs": "" }
+            }),
+        )
+        .await;
+
+        let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/plain")).await;
+        assert_eq!(result, None);
+    }
+
+    #[gpui::test]
+    async fn test_resolve_git_worktree_nonexistent_returns_none(cx: &mut TestAppContext) {
+        let fs = FakeFs::new(cx.executor());
+
+        let result =
+            resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/does-not-exist")).await;
+        assert_eq!(result, None);
+    }
+}

crates/recent_projects/src/recent_projects.rs 🔗

@@ -517,6 +517,7 @@ impl RecentProjects {
                 .await
                 .log_err()
                 .unwrap_or_default();
+            let workspaces = workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
             this.update_in(cx, move |this, window, cx| {
                 this.picker.update(cx, move |picker, cx| {
                     picker.delegate.set_workspaces(workspaces);
@@ -1510,6 +1511,8 @@ impl RecentProjectsDelegate {
                     .recent_workspaces_on_disk(fs.as_ref())
                     .await
                     .unwrap_or_default();
+                let workspaces =
+                    workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
                 this.update_in(cx, move |picker, window, cx| {
                     picker.delegate.set_workspaces(workspaces);
                     picker

crates/title_bar/src/title_bar.rs 🔗

@@ -169,6 +169,28 @@ impl Render for TitleBar {
 
         let mut children = Vec::new();
 
+        let mut project_name = None;
+        let mut repository = None;
+        let mut linked_worktree_name = None;
+        if let Some(worktree) = self.effective_active_worktree(cx) {
+            project_name = worktree
+                .read(cx)
+                .root_name()
+                .file_name()
+                .map(|name| SharedString::from(name.to_string()));
+            repository = self.get_repository_for_worktree(&worktree, cx);
+            linked_worktree_name = repository.as_ref().and_then(|repo| {
+                let path = repo.read(cx).linked_worktree_path()?;
+                let directory_name = path.file_name()?.to_str()?;
+                let unique_worktree_name = if directory_name != project_name.as_ref()?.as_str() {
+                    directory_name.to_string()
+                } else {
+                    path.parent()?.file_name()?.to_str()?.to_string()
+                };
+                Some(SharedString::from(unique_worktree_name))
+            });
+        }
+
         children.push(
             h_flex()
                 .h_full()
@@ -192,11 +214,18 @@ impl Render for TitleBar {
                                 .when(title_bar_settings.show_project_items, |title_bar| {
                                     title_bar
                                         .children(self.render_project_host(cx))
-                                        .child(self.render_project_name(window, cx))
-                                })
-                                .when(title_bar_settings.show_branch_name, |title_bar| {
-                                    title_bar.children(self.render_project_branch(cx))
+                                        .child(self.render_project_name(project_name, window, cx))
                                 })
+                                .when_some(
+                                    repository.filter(|_| title_bar_settings.show_branch_name),
+                                    |title_bar, repository| {
+                                        title_bar.children(self.render_project_branch(
+                                            repository,
+                                            linked_worktree_name,
+                                            cx,
+                                        ))
+                                    },
+                                )
                         })
                 })
                 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
@@ -739,14 +768,14 @@ impl TitleBar {
         )
     }
 
-    pub fn render_project_name(&self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render_project_name(
+        &self,
+        name: Option<SharedString>,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
         let workspace = self.workspace.clone();
 
-        let name = self.effective_active_worktree(cx).map(|worktree| {
-            let worktree = worktree.read(cx);
-            SharedString::from(worktree.root_name().as_unix_str().to_string())
-        });
-
         let is_project_selected = name.is_some();
 
         let display_name = if let Some(ref name) = name {
@@ -865,13 +894,17 @@ impl TitleBar {
             })
     }
 
-    pub fn render_project_branch(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
-        let effective_worktree = self.effective_active_worktree(cx)?;
-        let repository = self.get_repository_for_worktree(&effective_worktree, cx)?;
+    fn render_project_branch(
+        &self,
+        repository: Entity<project::git_store::Repository>,
+        linked_worktree_name: Option<SharedString>,
+        cx: &mut Context<Self>,
+    ) -> Option<impl IntoElement> {
         let workspace = self.workspace.upgrade()?;
 
         let (branch_name, icon_info) = {
             let repo = repository.read(cx);
+
             let branch_name = repo
                 .branch
                 .as_ref()
@@ -904,6 +937,13 @@ impl TitleBar {
             (branch_name, icon_info)
         };
 
+        let branch_name = branch_name?;
+        let button_text = if let Some(worktree_name) = linked_worktree_name {
+            format!("{}/{}", worktree_name, branch_name)
+        } else {
+            branch_name
+        };
+
         let settings = TitleBarSettings::get_global(cx);
 
         let effective_repository = Some(repository);
@@ -921,7 +961,7 @@ impl TitleBar {
                     ))
                 })
                 .trigger_with_tooltip(
-                    Button::new("project_branch_trigger", branch_name?)
+                    Button::new("project_branch_trigger", button_text)
                         .selected_style(ButtonStyle::Tinted(TintColor::Accent))
                         .label_size(LabelSize::Small)
                         .color(Color::Muted)

crates/workspace/src/persistence.rs 🔗

@@ -2359,6 +2359,86 @@ VALUES {placeholders};"#
     }
 }
 
+type WorkspaceEntry = (
+    WorkspaceId,
+    SerializedWorkspaceLocation,
+    PathList,
+    DateTime<Utc>,
+);
+
+/// Resolves workspace entries whose paths are git linked worktree checkouts
+/// to their main repository paths.
+///
+/// For each workspace entry:
+/// - If any path is a linked worktree checkout, all worktree paths in that
+///   entry are resolved to their main repository paths, producing a new
+///   `PathList`.
+/// - The resolved entry is then deduplicated against existing entries: if a
+///   workspace with the same paths already exists, the entry with the most
+///   recent timestamp is kept.
+pub async fn resolve_worktree_workspaces(
+    workspaces: impl IntoIterator<Item = WorkspaceEntry>,
+    fs: &dyn Fs,
+) -> Vec<WorkspaceEntry> {
+    // First pass: resolve worktree paths to main repo paths concurrently.
+    let resolved = futures::future::join_all(workspaces.into_iter().map(|entry| async move {
+        let paths = entry.2.paths();
+        if paths.is_empty() {
+            return entry;
+        }
+
+        // Resolve each path concurrently
+        let resolved_paths = futures::future::join_all(
+            paths
+                .iter()
+                .map(|path| project::git_store::resolve_git_worktree_to_main_repo(fs, path)),
+        )
+        .await;
+
+        // If no paths were resolved, this entry is not a worktree — keep as-is
+        if resolved_paths.iter().all(|r| r.is_none()) {
+            return entry;
+        }
+
+        // Build new path list, substituting resolved paths
+        let new_paths: Vec<PathBuf> = paths
+            .iter()
+            .zip(resolved_paths.iter())
+            .map(|(original, resolved)| {
+                resolved
+                    .as_ref()
+                    .cloned()
+                    .unwrap_or_else(|| original.clone())
+            })
+            .collect();
+
+        let new_path_refs: Vec<&Path> = new_paths.iter().map(|p| p.as_path()).collect();
+        (entry.0, entry.1, PathList::new(&new_path_refs), entry.3)
+    }))
+    .await;
+
+    // Second pass: deduplicate by PathList.
+    // When two entries resolve to the same paths, keep the one with the
+    // more recent timestamp.
+    let mut seen: collections::HashMap<Vec<PathBuf>, usize> = collections::HashMap::default();
+    let mut result: Vec<WorkspaceEntry> = Vec::new();
+
+    for entry in resolved {
+        let key: Vec<PathBuf> = entry.2.paths().to_vec();
+        if let Some(&existing_idx) = seen.get(&key) {
+            // Keep the entry with the more recent timestamp
+            if entry.3 > result[existing_idx].3 {
+                result[existing_idx] = entry;
+            }
+        } else {
+            seen.insert(key, result.len());
+            result.push(entry);
+        }
+    }
+
+    result
+}
+
 pub fn delete_unloaded_items(
     alive_items: Vec<ItemId>,
     workspace_id: WorkspaceId,
@@ -4489,4 +4569,116 @@ mod tests {
              before the process exits."
         );
     }
+
+    #[gpui::test]
+    async fn test_resolve_worktree_workspaces(cx: &mut gpui::TestAppContext) {
+        let fs = fs::FakeFs::new(cx.executor());
+
+        // Main repo with a linked worktree entry
+        fs.insert_tree(
+            "/repo",
+            json!({
+                ".git": {
+                    "worktrees": {
+                        "feature": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/feature"
+                        }
+                    }
+                },
+                "src": { "main.rs": "" }
+            }),
+        )
+        .await;
+
+        // Linked worktree checkout pointing back to /repo
+        fs.insert_tree(
+            "/worktree",
+            json!({
+                ".git": "gitdir: /repo/.git/worktrees/feature",
+                "src": { "main.rs": "" }
+            }),
+        )
+        .await;
+
+        // A plain non-git project
+        fs.insert_tree(
+            "/plain-project",
+            json!({
+                "src": { "main.rs": "" }
+            }),
+        )
+        .await;
+
+        // Another normal git repo (used in mixed-path entry)
+        fs.insert_tree(
+            "/other-repo",
+            json!({
+                ".git": {},
+                "src": { "lib.rs": "" }
+            }),
+        )
+        .await;
+
+        let t0 = Utc::now() - chrono::Duration::hours(4);
+        let t1 = Utc::now() - chrono::Duration::hours(3);
+        let t2 = Utc::now() - chrono::Duration::hours(2);
+        let t3 = Utc::now() - chrono::Duration::hours(1);
+
+        let workspaces = vec![
+            // 1: Main checkout of /repo (opened earlier)
+            (
+                WorkspaceId(1),
+                SerializedWorkspaceLocation::Local,
+                PathList::new(&["/repo"]),
+                t0,
+            ),
+            // 2: Linked worktree of /repo (opened more recently)
+            //    Should dedup with #1; more recent timestamp wins.
+            (
+                WorkspaceId(2),
+                SerializedWorkspaceLocation::Local,
+                PathList::new(&["/worktree"]),
+                t1,
+            ),
+            // 3: Mixed-path workspace: one root is a linked worktree,
+            //    the other is a normal repo. The worktree path should be
+            //    resolved; the normal path kept as-is.
+            (
+                WorkspaceId(3),
+                SerializedWorkspaceLocation::Local,
+                PathList::new(&["/other-repo", "/worktree"]),
+                t2,
+            ),
+            // 4: Non-git project — passed through unchanged.
+            (
+                WorkspaceId(4),
+                SerializedWorkspaceLocation::Local,
+                PathList::new(&["/plain-project"]),
+                t3,
+            ),
+        ];
+
+        let result = resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
+
+        // Should have 3 entries: #1 and #2 deduped into one, plus #3 and #4.
+        assert_eq!(result.len(), 3);
+
+        // First entry: /repo — deduplicated from #1 and #2.
+        // Keeps the position of #1 (first seen), but with #2's later timestamp.
+        assert_eq!(result[0].2.paths(), &[PathBuf::from("/repo")]);
+        assert_eq!(result[0].3, t1);
+
+        // Second entry: mixed-path workspace with worktree resolved.
+        // /worktree → /repo, so paths become [/other-repo, /repo] (sorted).
+        assert_eq!(
+            result[1].2.paths(),
+            &[PathBuf::from("/other-repo"), PathBuf::from("/repo")]
+        );
+        assert_eq!(result[1].0, WorkspaceId(3));
+
+        // Third entry: non-git project, unchanged.
+        assert_eq!(result[2].2.paths(), &[PathBuf::from("/plain-project")]);
+        assert_eq!(result[2].0, WorkspaceId(4));
+    }
 }

crates/workspace/src/workspace.rs 🔗

@@ -82,7 +82,7 @@ pub use persistence::{
         DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation,
         SessionWorkspace,
     },
-    read_serialized_multi_workspaces,
+    read_serialized_multi_workspaces, resolve_worktree_workspaces,
 };
 use postage::stream::Stream;
 use project::{