Improve bare repo support (#55153)

Fixes https://github.com/zed-industries/zed/issues/54830

This fixes a bugs where
* when there's no main worktree, we treated the first linked worktree as
main
* the titlebar and sidebar showed two different things when opening a
linked wortree directly

When there's no main worktree, our "project group key" will be the bare
repo path. For displaying this to the user, we try to present something
meaningful:
* If the bare repo is `foo.git`, we'll say "foo"
* If the bare repo is "bar/.bare", we'll "bar"

Release Notes:

- Fixed bugs in Zed's sidebar and titlebar when editing in git worktrees
created from bare repositories.
This commit is contained in:
Max Brunsfeld 2026-04-29 06:16:37 -07:00 committed by GitHub
parent d2bb6502bb
commit caccc65b1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 312 additions and 126 deletions

View file

@ -160,7 +160,7 @@ pub fn build_root_plan(
// Only linked worktrees can be archived to disk via `git worktree remove`.
// Main worktrees must be left alone — git refuses to remove them.
let (linked_snapshot, repo) = linked_repo?;
let main_repo_path = linked_snapshot.original_repo_abs_path.to_path_buf();
let main_repo_path = linked_snapshot.main_worktree_abs_path()?.to_path_buf();
// Only archive worktrees that live inside the Zed-managed worktrees
// directory (configured via `git.worktree_directory`). Worktrees the

View file

@ -112,6 +112,8 @@ CREATE TABLE "project_repositories" (
"remote_upstream_url" VARCHAR,
"remote_origin_url" VARCHAR,
"linked_worktrees" VARCHAR,
"repository_dir_abs_path" VARCHAR,
"common_dir_abs_path" VARCHAR,
PRIMARY KEY (project_id, id)
);

View file

@ -308,7 +308,9 @@ CREATE TABLE public.project_repositories (
merge_message character varying,
remote_upstream_url character varying,
remote_origin_url character varying,
linked_worktrees text
linked_worktrees text,
repository_dir_abs_path character varying,
common_dir_abs_path character varying
);
CREATE TABLE public.project_repository_statuses (
@ -333,7 +335,7 @@ CREATE TABLE public.projects (
host_connection_id integer,
host_connection_server_id integer,
windows_paths boolean DEFAULT false,
features text NOT NULL DEFAULT ''
features text DEFAULT ''::text NOT NULL
);
CREATE SEQUENCE public.projects_id_seq

View file

@ -379,6 +379,8 @@ impl Database {
merge_message: ActiveValue::set(update.merge_message.clone()),
remote_upstream_url: ActiveValue::set(update.remote_upstream_url.clone()),
remote_origin_url: ActiveValue::set(update.remote_origin_url.clone()),
repository_dir_abs_path: ActiveValue::set(update.repository_dir_abs_path.clone()),
common_dir_abs_path: ActiveValue::set(update.common_dir_abs_path.clone()),
linked_worktrees: ActiveValue::Set(Some(
serde_json::to_string(&update.linked_worktrees).unwrap(),
)),
@ -396,6 +398,8 @@ impl Database {
project_repository::Column::CurrentMergeConflicts,
project_repository::Column::HeadCommitDetails,
project_repository::Column::MergeMessage,
project_repository::Column::RepositoryDirAbsPath,
project_repository::Column::CommonDirAbsPath,
project_repository::Column::LinkedWorktrees,
])
.to_owned(),
@ -893,7 +897,8 @@ impl Database {
stash_entries: Vec::new(),
remote_upstream_url: db_repository_entry.remote_upstream_url.clone(),
remote_origin_url: db_repository_entry.remote_origin_url.clone(),
original_repo_abs_path: Some(db_repository_entry.abs_path),
repository_dir_abs_path: db_repository_entry.repository_dir_abs_path,
common_dir_abs_path: db_repository_entry.common_dir_abs_path,
linked_worktrees: db_repository_entry
.linked_worktrees
.as_deref()

View file

@ -800,7 +800,8 @@ impl Database {
stash_entries: Vec::new(),
remote_upstream_url: db_repository.remote_upstream_url.clone(),
remote_origin_url: db_repository.remote_origin_url.clone(),
original_repo_abs_path: Some(db_repository.abs_path),
repository_dir_abs_path: db_repository.repository_dir_abs_path,
common_dir_abs_path: db_repository.common_dir_abs_path,
linked_worktrees: db_repository
.linked_worktrees
.as_deref()

View file

@ -24,6 +24,8 @@ pub struct Model {
pub head_commit_details: Option<String>,
pub remote_upstream_url: Option<String>,
pub remote_origin_url: Option<String>,
pub repository_dir_abs_path: Option<String>,
pub common_dir_abs_path: Option<String>,
// JSON array of linked worktree objects
pub linked_worktrees: Option<String>,
}

View file

@ -951,11 +951,19 @@ async fn test_linked_worktrees_sync(
executor.run_until_parked();
// Verify host now sees 2 linked worktrees (feature-branch and hotfix-branch).
let host_linked_after_removal = project_a.read_with(cx_a, |project, cx| {
let repos = project.repositories(cx);
let repo = repos.values().next().unwrap();
repo.read(cx).linked_worktrees().to_vec()
});
let (host_linked_after_removal, host_git_paths_after_removal) =
project_a.read_with(cx_a, |project, cx| {
let repos = project.repositories(cx);
let repo = repos.values().next().unwrap();
let repo = repo.read(cx);
(
repo.linked_worktrees().to_vec(),
(
repo.repository_dir_abs_path.to_path_buf(),
repo.common_dir_abs_path.to_path_buf(),
),
)
});
assert_eq!(
host_linked_after_removal.len(),
2,
@ -998,6 +1006,19 @@ async fn test_linked_worktrees_sync(
late_joiner_linked, host_linked_after_removal,
"late-joining client's linked_worktrees should match host's (DB roundtrip)"
);
let late_joiner_git_paths = project_c.read_with(cx_c, |project, cx| {
let repos = project.repositories(cx);
let repo = repos.values().next().unwrap();
let repo = repo.read(cx);
(
repo.repository_dir_abs_path.to_path_buf(),
repo.common_dir_abs_path.to_path_buf(),
)
});
assert_eq!(
late_joiner_git_paths, host_git_paths_after_removal,
"late-joining client's git directory paths should match host's (DB roundtrip)"
);
// Test reconnection: disconnect client B (guest) and reconnect.
// After rejoining, client B should get linked_worktrees back from the DB.
@ -1010,20 +1031,32 @@ async fn test_linked_worktrees_sync(
executor.run_until_parked();
// Verify client B still has the correct linked worktrees after reconnection.
let guest_linked_after_reconnect = project_b.read_with(cx_b, |project, cx| {
let repos = project.repositories(cx);
assert_eq!(
repos.len(),
1,
"guest should still have exactly 1 repository after reconnect"
);
let repo = repos.values().next().unwrap();
repo.read(cx).linked_worktrees().to_vec()
});
let (guest_linked_after_reconnect, guest_git_paths_after_reconnect) =
project_b.read_with(cx_b, |project, cx| {
let repos = project.repositories(cx);
assert_eq!(
repos.len(),
1,
"guest should still have exactly 1 repository after reconnect"
);
let repo = repos.values().next().unwrap();
let repo = repo.read(cx);
(
repo.linked_worktrees().to_vec(),
(
repo.repository_dir_abs_path.to_path_buf(),
repo.common_dir_abs_path.to_path_buf(),
),
)
});
assert_eq!(
guest_linked_after_reconnect, host_linked_after_removal,
"guest's linked_worktrees should survive guest disconnect/reconnect"
);
assert_eq!(
guest_git_paths_after_reconnect, host_git_paths_after_removal,
"guest's git directory paths should survive guest disconnect/reconnect"
);
}
#[gpui::test]

View file

@ -349,9 +349,11 @@ impl Worktree {
}
}
pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
pub fn parse_worktrees_from_str<T: AsRef<str>>(
raw_worktrees: T,
main_worktree_path: Option<&Path>,
) -> Vec<Worktree> {
let mut worktrees = Vec::new();
let mut is_first = true;
let normalized = raw_worktrees.as_ref().replace("\r\n", "\n");
let entries = normalized.split("\n\n");
for entry in entries {
@ -379,14 +381,16 @@ pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree
}
if let (Some(path), Some(sha)) = (path, sha) {
let path = PathBuf::from(path);
let is_main =
main_worktree_path.is_some_and(|main_worktree_path| path == main_worktree_path);
worktrees.push(Worktree {
path: PathBuf::from(path),
path,
ref_name: ref_name.map(Into::into),
sha: sha.into(),
is_main: is_first,
is_main,
is_bare,
});
is_first = false;
}
}
@ -1831,6 +1835,11 @@ impl GitRepository for RealGitRepository {
fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
let git_binary = self.git_binary();
let main_worktree_path = {
let repo = self.repository.lock();
let common_dir = repo.commondir().to_path_buf();
original_repo_path_from_common_dir(&common_dir)
};
self.executor
.spawn(async move {
let git = git_binary?;
@ -1840,7 +1849,10 @@ impl GitRepository for RealGitRepository {
.await?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_worktrees_from_str(&stdout))
Ok(parse_worktrees_from_str(
&stdout,
main_worktree_path.as_deref(),
))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree list failed: {stderr}");
@ -4146,12 +4158,12 @@ mod tests {
#[test]
fn test_parse_worktrees_from_str() {
// Empty input
let result = parse_worktrees_from_str("");
let result = parse_worktrees_from_str("", None);
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);
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].sha.as_ref(), "abc123def");
@ -4160,23 +4172,23 @@ mod tests {
assert!(!result[0].is_bare);
// 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);
let input = "worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n\
worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n";
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
assert_eq!(result.len(), 2);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
assert!(result[0].is_main);
assert_eq!(result[0].path, PathBuf::from("/home/user/project-wt"));
assert_eq!(result[0].ref_name, Some("refs/heads/feature".into()));
assert!(!result[0].is_main);
assert!(!result[0].is_bare);
assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt"));
assert_eq!(result[1].ref_name, Some("refs/heads/feature".into()));
assert!(!result[1].is_main);
assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
assert!(result[1].is_main);
assert!(!result[1].is_bare);
// Detached HEAD entry (included with ref_name: None)
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);
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
assert_eq!(result.len(), 2);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
@ -4187,14 +4199,14 @@ mod tests {
assert!(!result[1].is_main);
assert!(!result[1].is_bare);
// Bare repo entry (included with ref_name: None)
// Bare repo entry with no main worktree.
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);
let result = parse_worktrees_from_str(input, None);
assert_eq!(result.len(), 2);
assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
assert_eq!(result[0].ref_name, None);
assert!(result[0].is_main);
assert!(!result[0].is_main);
assert!(result[0].is_bare);
assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
@ -4205,7 +4217,7 @@ mod tests {
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);
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
assert_eq!(result.len(), 3);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
@ -4223,7 +4235,7 @@ mod tests {
// 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);
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].sha.as_ref(), "abc123");
@ -4232,7 +4244,7 @@ mod tests {
// 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);
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].sha.as_ref(), "abc123");

View file

@ -301,11 +301,18 @@ pub struct RepositorySnapshot {
pub id: RepositoryId,
pub statuses_by_path: SumTree<StatusEntry>,
pub work_directory_abs_path: Arc<Path>,
/// The working directory of the original repository. For a normal
/// checkout this equals `work_directory_abs_path`. For a git worktree
/// checkout, this is the original repo's working directory — used to
/// anchor new worktree creation so they don't nest.
pub original_repo_abs_path: Arc<Path>,
/// Absolute path to the directory holding this worktree's Git state.
///
/// For a linked worktree this is the worktree-specific directory under the
/// common Git directory, such as `<main>/.git/worktrees/<name>`.
pub repository_dir_abs_path: Arc<Path>,
/// Absolute path to the repository's common Git directory.
///
/// For a normal checkout this is `<work_directory>/.git`. For a linked
/// worktree this is the common Git directory shared by all worktrees. If
/// that common directory is a bare repository, there may be no main
/// worktree path to derive from it.
pub common_dir_abs_path: Arc<Path>,
pub path_style: PathStyle,
pub branch: Option<Branch>,
pub branch_list: Arc<[Branch]>,
@ -1640,12 +1647,8 @@ impl GitStore {
..
} = update
{
let original_repo_abs_path: Arc<Path> = git::repository::original_repo_path(
work_directory_abs_path,
common_dir_abs_path,
repository_dir_abs_path,
)
.into();
let repository_dir_abs_path = repository_dir_abs_path.clone();
let common_dir_abs_path = common_dir_abs_path.clone();
let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release));
let is_trusted = TrustedWorktrees::try_get_global(cx)
.map(|trusted_worktrees| {
@ -1659,7 +1662,8 @@ impl GitStore {
let mut repo = Repository::local(
id,
work_directory_abs_path.clone(),
original_repo_abs_path.clone(),
repository_dir_abs_path.clone(),
common_dir_abs_path.clone(),
dot_git_abs_path.clone(),
project_environment.downgrade(),
fs.clone(),
@ -1902,9 +1906,10 @@ impl GitStore {
&self.repositories
}
/// Returns the original (main) repository working directory for the given worktree.
/// For normal checkouts this equals the worktree's own path; for linked
/// worktrees it points back to the original repo.
/// Returns the main repository working directory for the given worktree.
/// For normal checkouts this equals the worktree's own path. For linked
/// worktrees it points back to the main worktree, if one exists. Linked
/// worktrees attached to a bare repository have no main worktree path.
pub fn original_repo_path_for_worktree(
&self,
worktree_id: WorktreeId,
@ -1919,7 +1924,12 @@ impl GitStore {
.is_some_and(|ids| ids.contains(&worktree_id))
})
.and_then(|repo_id| self.repositories.get(repo_id))
.map(|repo| repo.read(cx).snapshot().original_repo_abs_path)
.and_then(|repo| {
repo.read(cx)
.snapshot()
.main_worktree_abs_path()
.map(Arc::from)
})
}
pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
@ -2064,8 +2074,12 @@ impl GitStore {
let id = RepositoryId::from_proto(update.id);
let client = this.upstream_client().context("no upstream client")?;
let original_repo_abs_path: Option<Arc<Path>> = update
.original_repo_abs_path
let repository_dir_abs_path: Option<Arc<Path>> = update
.repository_dir_abs_path
.as_deref()
.map(|p| Path::new(p).into());
let common_dir_abs_path: Option<Arc<Path>> = update
.common_dir_abs_path
.as_deref()
.map(|p| Path::new(p).into());
@ -2076,7 +2090,8 @@ impl GitStore {
Repository::remote(
id,
Path::new(&update.abs_path).into(),
original_repo_abs_path.clone(),
repository_dir_abs_path.clone(),
common_dir_abs_path.clone(),
path_style,
ProjectId(update.project_id),
client,
@ -3926,14 +3941,20 @@ impl RepositorySnapshot {
fn empty(
id: RepositoryId,
work_directory_abs_path: Arc<Path>,
original_repo_abs_path: Option<Arc<Path>>,
repository_dir_abs_path: Option<Arc<Path>>,
common_dir_abs_path: Option<Arc<Path>>,
path_style: PathStyle,
) -> Self {
let repository_dir_abs_path =
repository_dir_abs_path.unwrap_or_else(|| work_directory_abs_path.join(".git").into());
let common_dir_abs_path =
common_dir_abs_path.unwrap_or_else(|| repository_dir_abs_path.clone());
Self {
id,
statuses_by_path: Default::default(),
original_repo_abs_path: original_repo_abs_path
.unwrap_or_else(|| work_directory_abs_path.clone()),
repository_dir_abs_path,
common_dir_abs_path,
work_directory_abs_path,
branch: None,
branch_list: Arc::from([]),
@ -3980,9 +4001,10 @@ impl RepositorySnapshot {
.collect(),
remote_upstream_url: self.remote_upstream_url.clone(),
remote_origin_url: self.remote_origin_url.clone(),
original_repo_abs_path: Some(
self.original_repo_abs_path.to_string_lossy().into_owned(),
repository_dir_abs_path: Some(
self.repository_dir_abs_path.to_string_lossy().into_owned(),
),
common_dir_abs_path: Some(self.common_dir_abs_path.to_string_lossy().into_owned()),
linked_worktrees: self
.linked_worktrees
.iter()
@ -4062,9 +4084,10 @@ impl RepositorySnapshot {
.collect(),
remote_upstream_url: self.remote_upstream_url.clone(),
remote_origin_url: self.remote_origin_url.clone(),
original_repo_abs_path: Some(
self.original_repo_abs_path.to_string_lossy().into_owned(),
repository_dir_abs_path: Some(
self.repository_dir_abs_path.to_string_lossy().into_owned(),
),
common_dir_abs_path: Some(self.common_dir_abs_path.to_string_lossy().into_owned()),
linked_worktrees: self
.linked_worktrees
.iter()
@ -4073,6 +4096,23 @@ impl RepositorySnapshot {
}
}
/// Returns the main worktree path for this repository, if one exists.
///
/// Linked worktrees attached to bare repositories do not have a main
/// worktree. For linked worktrees attached to a non-bare repository, the
/// common Git directory is the main worktree's `.git` directory.
pub fn main_worktree_abs_path(&self) -> Option<&Path> {
if self.is_linked_worktree() {
if self.common_dir_abs_path.file_name()? == std::ffi::OsStr::new(".git") {
self.common_dir_abs_path.parent()
} else {
None
}
} else {
Some(self.work_directory_abs_path.as_ref())
}
}
/// The main worktree is the original checkout that other worktrees were
/// created from.
///
@ -4081,7 +4121,7 @@ impl RepositorySnapshot {
///
/// Submodules also return `true` here, since they are not linked worktrees.
pub fn is_main_worktree(&self) -> bool {
self.work_directory_abs_path == self.original_repo_abs_path
!self.is_linked_worktree()
}
/// Returns true if this repository is a linked worktree, that is, one that
@ -4089,7 +4129,7 @@ impl RepositorySnapshot {
///
/// Returns `false` for both the main worktree and submodules.
pub fn is_linked_worktree(&self) -> bool {
!self.is_main_worktree()
self.repository_dir_abs_path != self.common_dir_abs_path
}
pub fn linked_worktrees(&self) -> &[GitWorktree] {
@ -4266,7 +4306,8 @@ impl Repository {
fn local(
id: RepositoryId,
work_directory_abs_path: Arc<Path>,
original_repo_abs_path: Arc<Path>,
repository_dir_abs_path: Arc<Path>,
common_dir_abs_path: Arc<Path>,
dot_git_abs_path: Arc<Path>,
project_environment: WeakEntity<ProjectEnvironment>,
fs: Arc<dyn Fs>,
@ -4277,7 +4318,8 @@ impl Repository {
let snapshot = RepositorySnapshot::empty(
id,
work_directory_abs_path.clone(),
Some(original_repo_abs_path),
Some(repository_dir_abs_path),
Some(common_dir_abs_path),
PathStyle::local(),
);
let refetch_repo_state = Arc::new(move |cx: &mut Context<Self>| {
@ -4353,7 +4395,8 @@ impl Repository {
fn remote(
id: RepositoryId,
work_directory_abs_path: Arc<Path>,
original_repo_abs_path: Option<Arc<Path>>,
repository_dir_abs_path: Option<Arc<Path>>,
common_dir_abs_path: Option<Arc<Path>>,
path_style: PathStyle,
project_id: ProjectId,
client: AnyProtoClient,
@ -4363,7 +4406,8 @@ impl Repository {
let snapshot = RepositorySnapshot::empty(
id,
work_directory_abs_path,
original_repo_abs_path,
repository_dir_abs_path,
common_dir_abs_path,
path_style,
);
let refetch_repo_state = Arc::new(move |cx: &mut Context<Self>| {
@ -6468,15 +6512,13 @@ impl Repository {
}
/// If this is a linked worktree (*NOT* the main checkout of a repository),
/// returns the pathed for the linked worktree.
/// returns the path 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
}
self.snapshot
.is_linked_worktree()
.then_some(&self.work_directory_abs_path)
}
pub fn path_for_new_linked_worktree(
@ -6484,11 +6526,15 @@ impl Repository {
branch_name: &str,
worktree_directory_setting: &str,
) -> Result<PathBuf> {
let original_repo = self.original_repo_abs_path.clone();
let project_name = original_repo
let repository_anchor = self
.snapshot
.main_worktree_abs_path()
.unwrap_or(self.common_dir_abs_path.as_ref());
let project_name = repository_anchor
.file_name()
.ok_or_else(|| anyhow!("git repo must have a directory name"))?;
let directory = worktrees_directory_for_repo(&original_repo, worktree_directory_setting)?;
let directory =
worktrees_directory_for_repo(repository_anchor, worktree_directory_setting)?;
Ok(directory.join(branch_name).join(project_name))
}
@ -6738,7 +6784,11 @@ impl Repository {
pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver<Result<()>> {
let id = self.id;
let original_repo_abs_path = self.snapshot.original_repo_abs_path.clone();
let repository_anchor_path: Arc<Path> = self
.snapshot
.main_worktree_abs_path()
.unwrap_or(self.snapshot.common_dir_abs_path.as_ref())
.into();
self.send_job(
Some(format!("git worktree remove: {}", path.display()).into()),
move |repo, cx| async move {
@ -6781,7 +6831,7 @@ impl Repository {
let managed_worktree_base = cx.update(|cx| {
let setting = &ProjectSettings::get_global(cx).git.worktree_directory;
worktrees_directory_for_repo(&original_repo_abs_path, setting).log_err()
worktrees_directory_for_repo(&repository_anchor_path, setting).log_err()
});
if let Some(managed_worktree_base) = managed_worktree_base {
@ -7159,8 +7209,12 @@ impl Repository {
update: proto::UpdateRepository,
cx: &mut Context<Self>,
) -> Result<()> {
if let Some(main_path) = &update.original_repo_abs_path {
self.snapshot.original_repo_abs_path = Path::new(main_path.as_str()).into();
if let Some(repository_dir_abs_path) = &update.repository_dir_abs_path {
self.snapshot.repository_dir_abs_path =
Path::new(repository_dir_abs_path.as_str()).into();
}
if let Some(common_dir_abs_path) = &update.common_dir_abs_path {
self.snapshot.common_dir_abs_path = Path::new(common_dir_abs_path.as_str()).into();
}
let new_branch = update.branch_summary.as_ref().map(proto_to_branch);
@ -7793,7 +7847,7 @@ pub async fn resolve_git_worktree_to_main_repo(fs: &dyn Fs, path: &Path) -> Opti
///
/// Returns `Ok(resolved_path)` or an error with a user-facing message.
pub fn worktrees_directory_for_repo(
original_repo_abs_path: &Path,
repository_anchor_path: &Path,
worktree_directory_setting: &str,
) -> Result<PathBuf> {
// Check the original setting before trimming, since a path like "///"
@ -7819,25 +7873,25 @@ pub fn worktrees_directory_for_repo(
anyhow::bail!("git.worktree_directory must not be \"..\" (use \"../some-name\" instead)");
}
let joined = original_repo_abs_path.join(trimmed);
let joined = repository_anchor_path.join(trimmed);
let resolved = util::normalize_path(&joined);
let resolved = if resolved.starts_with(original_repo_abs_path) {
let resolved = if resolved.starts_with(repository_anchor_path) {
resolved
} else if let Some(repo_dir_name) = original_repo_abs_path.file_name() {
} else if let Some(repo_dir_name) = repository_anchor_path.file_name() {
resolved.join(repo_dir_name)
} else {
resolved
};
let parent = original_repo_abs_path
let parent = repository_anchor_path
.parent()
.unwrap_or(original_repo_abs_path);
.unwrap_or(repository_anchor_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."
subdirectory of {repository_anchor_path:?} or a sibling of it."
);
}
@ -7884,6 +7938,29 @@ async fn remove_empty_managed_worktree_ancestors(fs: &dyn Fs, child_path: &Path,
}
}
/// Returns the repository's identity path given its common Git directory.
///
/// This is the canonical, on-disk path used for project grouping and as the
/// basis for display names. The goal is to return the directory the user
/// thinks of as "the project":
///
/// - If `common_dir`'s last component starts with `.` (e.g. `.git` for a
/// normal checkout, or `.bare` for a bare clone), the parent directory is
/// returned. Both of these are internal Git directories; the parent is the
/// meaningful project root.
/// - Otherwise (e.g. `zed.git` for a bare clone), `common_dir` itself is
/// returned — it is already a meaningful on-disk path.
pub fn repo_identity_path(common_dir: &Path) -> &Path {
let is_dot_entry = common_dir
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with('.'));
if is_dot_entry {
common_dir.parent().unwrap_or(common_dir)
} else {
common_dir
}
}
/// Returns a short name for a linked worktree suitable for UI display
///
/// Uses the main worktree path to come up with a short name that disambiguates

View file

@ -49,7 +49,7 @@ pub use agent_server_store::{AgentId, AgentServerStore, AgentServersUpdated, Ext
pub use git_store::{
ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate,
git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal},
linked_worktree_short_name, worktrees_directory_for_repo,
linked_worktree_short_name, repo_identity_path, worktrees_directory_for_repo,
};
pub use manifest_tree::ManifestTree;
pub use project_search::{Search, SearchResults};
@ -6204,7 +6204,14 @@ impl ProjectGroupKey {
let mut names = Vec::with_capacity(self.paths.paths().len());
for abs_path in self.paths.ordered_paths() {
let detail = path_detail_map.get(abs_path).copied().unwrap_or(0);
let suffix = path_suffix(abs_path, detail);
// Strip a `.git` extension for display (bare clones like `foo.git`
// should display as `foo`, matching the titlebar).
let display_path = if abs_path.extension() == Some(std::ffi::OsStr::new("git")) {
std::borrow::Cow::Owned(abs_path.with_extension(""))
} else {
std::borrow::Cow::Borrowed(abs_path.as_path())
};
let suffix = path_suffix(&display_path, detail);
if !suffix.is_empty() {
names.push(suffix);
}

View file

@ -1369,7 +1369,7 @@ impl WorktreeStore {
let folder_path = snapshot.abs_path().to_path_buf();
let main_path = snapshot
.root_repo_common_dir()
.and_then(|dir| Some(dir.parent()?.to_path_buf()))
.map(|dir| crate::git_store::repo_identity_path(dir).to_path_buf())
.unwrap_or_else(|| folder_path.clone());
(main_path, folder_path)
})

View file

@ -1609,7 +1609,10 @@ mod trust_tests {
mod resolve_worktree_tests {
use fs::FakeFs;
use gpui::TestAppContext;
use project::{git_store::resolve_git_worktree_to_main_repo, linked_worktree_short_name};
use project::{
git_store::resolve_git_worktree_to_main_repo, linked_worktree_short_name,
repo_identity_path,
};
use serde_json::json;
use std::path::{Path, PathBuf};
@ -1687,6 +1690,27 @@ mod resolve_worktree_tests {
assert_eq!(result, None);
}
#[test]
fn test_repo_identity_path() {
let examples = [
// Normal checkout: `.git` starts with `.`, so parent is the worktree
("/home/bob/zed/.git", "/home/bob/zed"),
// Bare clone named `.bare`: starts with `.`, so parent is the project dir
("/repos/project/.bare", "/repos/project"),
// Bare clone with `.git` extension: does not start with `.`, kept as-is
("/repos/zed.git", "/repos/zed.git"),
// Bare clone with arbitrary plain name: kept as-is
("/repos/project", "/repos/project"),
];
for (common_dir, expected) in examples {
assert_eq!(
repo_identity_path(Path::new(common_dir)),
Path::new(expected),
"identity path for common_dir {common_dir:?} should be {expected:?}"
);
}
}
#[test]
fn test_linked_worktree_short_name() {
let examples = [

View file

@ -12068,8 +12068,8 @@ async fn test_git_worktrees_and_submodules(cx: &mut gpui::TestAppContext) {
Path::new(path!("/project/some-worktree")).into(),
);
pretty_assertions::assert_eq!(
repo.read(cx).original_repo_abs_path,
Path::new(path!("/project")).into(),
repo.read(cx).main_worktree_abs_path(),
Some(Path::new(path!("/project"))),
);
assert!(
repo.read(cx).linked_worktree_path().is_some(),
@ -12121,8 +12121,8 @@ async fn test_git_worktrees_and_submodules(cx: &mut gpui::TestAppContext) {
Path::new(path!("/project/subdir/some-submodule")).into(),
);
pretty_assertions::assert_eq!(
repo.read(cx).original_repo_abs_path,
Path::new(path!("/project/subdir/some-submodule")).into(),
repo.read(cx).main_worktree_abs_path(),
Some(Path::new(path!("/project/subdir/some-submodule"))),
);
assert!(
repo.read(cx).linked_worktree_path().is_none(),

View file

@ -125,9 +125,11 @@ message UpdateRepository {
repeated StashEntry stash_entries = 13;
optional string remote_upstream_url = 14;
optional string remote_origin_url = 15;
optional string original_repo_abs_path = 16;
reserved 16;
repeated Worktree linked_worktrees = 17;
repeated Branch branch_list = 18;
optional string repository_dir_abs_path = 19;
optional string common_dir_abs_path = 20;
}
message RemoveRepository {

View file

@ -383,11 +383,12 @@ fn workspace_menu_worktree_labels(
if let Some(snapshot) = repository_snapshot {
let worktree_name = if snapshot.is_linked_worktree() {
project::linked_worktree_short_name(
snapshot.original_repo_abs_path.as_ref(),
root_path,
)
.unwrap_or_else(|| folder_name.clone())
snapshot
.main_worktree_abs_path()
.and_then(|main_worktree_path| {
project::linked_worktree_short_name(main_worktree_path, root_path)
})
.unwrap_or_else(|| folder_name.clone())
} else {
"main".into()
};
@ -5246,7 +5247,7 @@ fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::
.find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path);
let main_worktree_path = repo_info.and_then(|s| s.main_worktree_abs_path());
let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
write!(output, " - {}", abs_path.display()).ok();
@ -5257,8 +5258,13 @@ fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::
write!(output, " [branch: {branch}]").ok();
}
if is_linked {
if let Some(original) = original_repo_path {
write!(output, " [linked worktree -> {}]", original.display()).ok();
if let Some(main_worktree_path) = main_worktree_path {
write!(
output,
" [linked worktree -> {}]",
main_worktree_path.display()
)
.ok();
} else {
write!(output, " [linked worktree]").ok();
}

View file

@ -9692,8 +9692,10 @@ mod property_test {
for workspace in group_workspaces {
for snapshot in root_repository_snapshots(workspace, cx) {
let repo_path_list =
PathList::new(&[snapshot.original_repo_abs_path.to_path_buf()]);
let Some(main_worktree_abs_path) = snapshot.main_worktree_abs_path() else {
continue;
};
let repo_path_list = PathList::new(&[main_worktree_abs_path.to_path_buf()]);
if repo_path_list != path_list {
continue;
}

View file

@ -14,7 +14,7 @@ pub use platform_title_bar::{
self, DraggedWindowTab, MergeAllWindows, MoveTabToNewWindow, PlatformTitleBar,
ShowNextWindowTab, ShowPreviousWindowTab,
};
use project::linked_worktree_short_name;
use project::{linked_worktree_short_name, repo_identity_path};
#[cfg(not(target_os = "macos"))]
use crate::application_menu::{
@ -197,16 +197,27 @@ impl Render for TitleBar {
.map(|name| SharedString::from(name.to_string()));
if let Some(repo) = &repository {
let repo = repo.read(cx);
linked_worktree_name = linked_worktree_short_name(
repo.original_repo_abs_path.as_ref(),
repo.work_directory_abs_path.as_ref(),
);
if let Some(name) = repo
.original_repo_abs_path
.file_name()
.and_then(|name| name.to_str())
{
project_name = Some(SharedString::from(name.to_string()));
linked_worktree_name = repo
.main_worktree_abs_path()
.and_then(|main_worktree_path| {
linked_worktree_short_name(
main_worktree_path,
repo.work_directory_abs_path.as_ref(),
)
})
.or_else(|| {
repo.is_linked_worktree()
.then_some(project_name.clone())
.flatten()
});
let identity = repo_identity_path(&repo.common_dir_abs_path);
let display_name = if identity.extension() == Some(std::ffi::OsStr::new("git")) {
identity.file_stem()
} else {
identity.file_name()
};
if let Some(name) = display_name.and_then(|n| n.to_str()) {
project_name = Some(name.into());
}
}
}