multiworkspace: Don't destroy workspace when git worktree is detached head (#51977)

When a git worktree is in a detached HEAD state (e.g. after `git
checkout --detach`), the workspace for that worktree would disappear
from the sidebar UI, and if the workspace was currently open your UI
would just disappear.

This happened because `parse_worktrees_from_str` silently dropped any
worktree entries without a `branch` line in the porcelain output, which
cascaded through the `linked_worktrees` list and caused
`prune_stale_worktree_workspaces` to remove the workspace.

This PR:
- Makes `Worktree::ref_name` an `Option<SharedString>` so detached
worktrees can be represented
- Renames `Worktree::branch()` to `Worktree::display_name()`, which
returns the branch name when available or the short SHA as a fallback
(mirroring how the title bar already handles detached HEAD for
repositories)
- Updates `parse_worktrees_from_str` to include detached and bare
worktree entries instead of dropping them
- Filters detached worktrees out of the worktree picker UI, preserving
the existing product decision to only show branch-based worktrees there

## Self-Review Checklist

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Eric Holk 2026-03-19 17:18:08 -07:00 committed by GitHub
parent 1dfe836d3d
commit 2d7c7200cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 107 additions and 67 deletions

View file

@ -235,7 +235,10 @@ async fn test_remote_git_worktrees(
assert_eq!(worktrees.len(), 2);
assert_eq!(worktrees[0].path, PathBuf::from(path!("/project")));
assert_eq!(worktrees[1].path, worktree_directory.join("feature-branch"));
assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch");
assert_eq!(
worktrees[1].ref_name,
Some("refs/heads/feature-branch".into())
);
assert_eq!(worktrees[1].sha.as_ref(), "abc123");
// Verify from the host side that the worktree was actually created
@ -287,7 +290,7 @@ async fn test_remote_git_worktrees(
let feature_worktree = worktrees
.iter()
.find(|worktree| worktree.ref_name.as_ref() == "refs/heads/feature-branch")
.find(|worktree| worktree.ref_name == Some("refs/heads/feature-branch".into()))
.expect("should find feature-branch worktree");
assert_eq!(
feature_worktree.path,
@ -296,7 +299,7 @@ async fn test_remote_git_worktrees(
let bugfix_worktree = worktrees
.iter()
.find(|worktree| worktree.ref_name.as_ref() == "refs/heads/bugfix-branch")
.find(|worktree| worktree.ref_name == Some("refs/heads/bugfix-branch".into()))
.expect("should find bugfix-branch worktree");
assert_eq!(
bugfix_worktree.path,
@ -396,17 +399,17 @@ async fn test_linked_worktrees_sync(
.with_git_state(Path::new(path!("/project/.git")), true, |state| {
state.worktrees.push(GitWorktree {
path: PathBuf::from(path!("/project")),
ref_name: "refs/heads/main".into(),
ref_name: Some("refs/heads/main".into()),
sha: "aaa111".into(),
});
state.worktrees.push(GitWorktree {
path: PathBuf::from(path!("/project/feature-branch")),
ref_name: "refs/heads/feature-branch".into(),
ref_name: Some("refs/heads/feature-branch".into()),
sha: "bbb222".into(),
});
state.worktrees.push(GitWorktree {
path: PathBuf::from(path!("/project/bugfix-branch")),
ref_name: "refs/heads/bugfix-branch".into(),
ref_name: Some("refs/heads/bugfix-branch".into()),
sha: "ccc333".into(),
});
})
@ -434,15 +437,18 @@ async fn test_linked_worktrees_sync(
PathBuf::from(path!("/project/feature-branch"))
);
assert_eq!(
host_linked[0].ref_name.as_ref(),
"refs/heads/feature-branch"
host_linked[0].ref_name,
Some("refs/heads/feature-branch".into())
);
assert_eq!(host_linked[0].sha.as_ref(), "bbb222");
assert_eq!(
host_linked[1].path,
PathBuf::from(path!("/project/bugfix-branch"))
);
assert_eq!(host_linked[1].ref_name.as_ref(), "refs/heads/bugfix-branch");
assert_eq!(
host_linked[1].ref_name,
Some("refs/heads/bugfix-branch".into())
);
assert_eq!(host_linked[1].sha.as_ref(), "ccc333");
// Share the project and have client B join.
@ -472,7 +478,7 @@ async fn test_linked_worktrees_sync(
.with_git_state(Path::new(path!("/project/.git")), true, |state| {
state.worktrees.push(GitWorktree {
path: PathBuf::from(path!("/project/hotfix-branch")),
ref_name: "refs/heads/hotfix-branch".into(),
ref_name: Some("refs/heads/hotfix-branch".into()),
sha: "ddd444".into(),
});
})
@ -514,7 +520,7 @@ async fn test_linked_worktrees_sync(
.with_git_state(Path::new(path!("/project/.git")), true, |state| {
state
.worktrees
.retain(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch");
.retain(|wt| wt.ref_name != Some("refs/heads/bugfix-branch".into()));
})
.unwrap();
@ -534,7 +540,7 @@ async fn test_linked_worktrees_sync(
assert!(
host_linked_after_removal
.iter()
.all(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch"),
.all(|wt| wt.ref_name != Some("refs/heads/bugfix-branch".into())),
"bugfix-branch should have been removed"
);

View file

@ -491,7 +491,10 @@ async fn test_ssh_collaboration_git_worktrees(
.unwrap();
assert_eq!(worktrees.len(), 2);
assert_eq!(worktrees[1].path, worktree_directory.join("feature-branch"));
assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch");
assert_eq!(
worktrees[1].ref_name,
Some("refs/heads/feature-branch".into())
);
assert_eq!(worktrees[1].sha.as_ref(), "abc123");
let server_worktrees = {

View file

@ -427,7 +427,7 @@ impl GitRepository for FakeGitRepository {
.unwrap_or_else(|| "refs/heads/main".to_string());
let main_worktree = Worktree {
path: work_dir,
ref_name: branch_ref.into(),
ref_name: Some(branch_ref.into()),
sha: head_sha.into(),
};
let mut all = vec![main_worktree];
@ -468,7 +468,7 @@ impl GitRepository for FakeGitRepository {
state.refs.insert(ref_name.clone(), sha.clone());
state.worktrees.push(Worktree {
path,
ref_name: ref_name.into(),
ref_name: Some(ref_name.into()),
sha: sha.into(),
});
state.branches.insert(branch_name);

View file

@ -36,7 +36,10 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
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].ref_name,
Some("refs/heads/feature-branch".into())
);
assert_eq!(worktrees[1].sha.as_ref(), "abc123");
// Directory should exist in FakeFs after create

View file

@ -212,18 +212,25 @@ impl Branch {
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct Worktree {
pub path: PathBuf,
pub ref_name: SharedString,
pub ref_name: Option<SharedString>,
// todo(git_worktree) This type should be a Oid
pub sha: SharedString,
}
impl Worktree {
pub fn branch(&self) -> &str {
self.ref_name
.as_ref()
.strip_prefix("refs/heads/")
.or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
.unwrap_or(self.ref_name.as_ref())
/// Returns a display name for the worktree, suitable for use in the UI.
///
/// If the worktree is attached to a branch, returns the branch name.
/// Otherwise, returns the short SHA of the worktree's HEAD commit.
pub fn display_name(&self) -> &str {
match self.ref_name {
Some(ref ref_name) => ref_name
.strip_prefix("refs/heads/")
.or_else(|| ref_name.strip_prefix("refs/remotes/"))
.unwrap_or(ref_name),
// Detached HEAD — show the short SHA as a fallback.
None => &self.sha[..self.sha.len().min(SHORT_SHA_LENGTH)],
}
}
}
@ -251,12 +258,10 @@ pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree
// Ignore other lines: detached, bare, locked, prunable, etc.
}
// todo(git_worktree) We should add a test for detach head state
// a detach head will have ref_name as none so we would skip it
if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) {
if let (Some(path), Some(sha)) = (path, sha) {
worktrees.push(Worktree {
path: PathBuf::from(path),
ref_name: ref_name.into(),
ref_name: ref_name.map(Into::into),
sha: sha.into(),
})
}
@ -3793,7 +3798,7 @@ mod tests {
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].sha.as_ref(), "abc123def");
assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
// Multiple worktrees
let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
@ -3801,23 +3806,30 @@ mod tests {
let result = parse_worktrees_from_str(input);
assert_eq!(result.len(), 2);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt"));
assert_eq!(result[1].ref_name.as_ref(), "refs/heads/feature");
assert_eq!(result[1].ref_name, Some("refs/heads/feature".into()));
// Detached HEAD entry (should be skipped since ref_name won't parse)
// 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);
assert_eq!(result.len(), 1);
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_eq!(result[1].path, PathBuf::from("/home/user/detached"));
assert_eq!(result[1].ref_name, None);
assert_eq!(result[1].sha.as_ref(), "def456");
// Bare repo entry (should be skipped)
// Bare repo entry (included with ref_name: None)
let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n";
let result = parse_worktrees_from_str(input);
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result.len(), 2);
assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
assert_eq!(result[0].ref_name, None);
assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
// Extra porcelain lines (locked, prunable) should be ignored
let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
@ -3826,11 +3838,14 @@ mod tests {
let result = parse_worktrees_from_str(input);
assert_eq!(result.len(), 3);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt"));
assert_eq!(result[1].ref_name.as_ref(), "refs/heads/locked-branch");
assert_eq!(result[1].ref_name, Some("refs/heads/locked-branch".into()));
assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt"));
assert_eq!(result[2].ref_name.as_ref(), "refs/heads/prunable-branch");
assert_eq!(
result[2].ref_name,
Some("refs/heads/prunable-branch".into())
);
// Leading/trailing whitespace on lines should be tolerated
let input =
@ -3839,7 +3854,7 @@ mod tests {
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].sha.as_ref(), "abc123");
assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
// 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";
@ -3847,7 +3862,7 @@ mod tests {
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].sha.as_ref(), "abc123");
assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
}
#[gpui::test]
@ -3914,7 +3929,7 @@ mod tests {
let new_worktree = worktrees
.iter()
.find(|w| w.branch() == "test-branch")
.find(|w| w.display_name() == "test-branch")
.expect("should find worktree with test-branch");
assert_eq!(
new_worktree.path.canonicalize().unwrap(),
@ -3976,7 +3991,7 @@ mod tests {
let worktrees = repo.worktrees().await.unwrap();
assert_eq!(worktrees.len(), 1);
assert!(
worktrees.iter().all(|w| w.branch() != "to-remove"),
worktrees.iter().all(|w| w.display_name() != "to-remove"),
"removed worktree should not appear in list"
);
assert!(!worktree_path.exists());
@ -4078,7 +4093,7 @@ mod tests {
assert_eq!(worktrees.len(), 2);
let moved_worktree = worktrees
.iter()
.find(|w| w.branch() == "old-name")
.find(|w| w.display_name() == "old-name")
.expect("should find worktree by branch name");
assert_eq!(
moved_worktree.path.canonicalize().unwrap(),

View file

@ -96,9 +96,12 @@ impl WorktreeList {
});
cx.spawn_in(window, async move |this, cx| {
let all_worktrees = all_worktrees_request
let all_worktrees: Vec<_> = all_worktrees_request
.context("No active repository")?
.await??;
.await??
.into_iter()
.filter(|worktree| worktree.ref_name.is_some()) // hide worktrees without a branch
.collect();
let default_branch = default_branch_request
.context("No active repository")?
@ -182,7 +185,7 @@ impl WorktreeList {
return;
}
picker.delegate.create_worktree(
entry.worktree.branch(),
entry.worktree.display_name(),
replace_current_window,
Some(default_branch.into()),
window,
@ -649,7 +652,7 @@ impl PickerDelegate for WorktreeListDelegate {
let candidates = all_worktrees
.iter()
.enumerate()
.map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.branch()))
.map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.display_name()))
.collect::<Vec<StringMatchCandidate>>();
fuzzy::match_strings(
&candidates,
@ -674,13 +677,13 @@ impl PickerDelegate for WorktreeListDelegate {
if !query.is_empty()
&& !matches
.first()
.is_some_and(|entry| entry.worktree.branch() == query)
.is_some_and(|entry| entry.worktree.display_name() == query)
{
let query = query.replace(' ', "-");
matches.push(WorktreeEntry {
worktree: GitWorktree {
path: Default::default(),
ref_name: format!("refs/heads/{query}").into(),
ref_name: Some(format!("refs/heads/{query}").into()),
sha: Default::default(),
},
positions: Vec::new(),
@ -706,7 +709,7 @@ impl PickerDelegate for WorktreeListDelegate {
return;
};
if entry.is_new {
self.create_worktree(&entry.worktree.branch(), secondary, None, window, cx);
self.create_worktree(&entry.worktree.display_name(), secondary, None, window, cx);
} else {
self.open_worktree(&entry.worktree.path, secondary, window, cx);
}
@ -737,16 +740,19 @@ impl PickerDelegate for WorktreeListDelegate {
let (branch_name, sublabel) = if entry.is_new {
(
Label::new(format!("Create Worktree: \"{}\"", entry.worktree.branch()))
.truncate()
.into_any_element(),
Label::new(format!(
"Create Worktree: \"{}\"",
entry.worktree.display_name()
))
.truncate()
.into_any_element(),
format!(
"based off {}",
self.base_branch(cx).unwrap_or("the current branch")
),
)
} else {
let branch = entry.worktree.branch();
let branch = entry.worktree.display_name();
let branch_first_line = branch.lines().next().unwrap_or(branch);
let positions: Vec<_> = entry
.positions

View file

@ -7018,7 +7018,11 @@ fn branch_to_proto(branch: &git::repository::Branch) -> proto::Branch {
fn worktree_to_proto(worktree: &git::repository::Worktree) -> proto::Worktree {
proto::Worktree {
path: worktree.path.to_string_lossy().to_string(),
ref_name: worktree.ref_name.to_string(),
ref_name: worktree
.ref_name
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
sha: worktree.sha.to_string(),
}
}
@ -7026,7 +7030,7 @@ fn worktree_to_proto(worktree: &git::repository::Worktree) -> proto::Worktree {
fn proto_to_worktree(proto: &proto::Worktree) -> git::repository::Worktree {
git::repository::Worktree {
path: PathBuf::from(proto.path.clone()),
ref_name: proto.ref_name.clone().into(),
ref_name: Some(SharedString::from(&proto.ref_name)),
sha: proto.sha.clone().into(),
}
}

View file

@ -1287,7 +1287,10 @@ mod git_worktrees {
assert_eq!(worktrees.len(), 2);
assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
assert_eq!(worktrees[1].path, worktree_1_directory);
assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch");
assert_eq!(
worktrees[1].ref_name,
Some("refs/heads/feature-branch".into())
);
assert_eq!(worktrees[1].sha.as_ref(), "abc123");
let worktree_2_directory = worktrees_directory.join("bugfix-branch");
@ -1316,13 +1319,13 @@ mod git_worktrees {
let worktree_1 = worktrees
.iter()
.find(|worktree| worktree.ref_name.as_ref() == "refs/heads/feature-branch")
.find(|worktree| worktree.ref_name == Some("refs/heads/feature-branch".into()))
.expect("should find feature-branch worktree");
assert_eq!(worktree_1.path, worktree_1_directory);
let worktree_2 = worktrees
.iter()
.find(|worktree| worktree.ref_name.as_ref() == "refs/heads/bugfix-branch")
.find(|worktree| worktree.ref_name == Some("refs/heads/bugfix-branch".into()))
.expect("should find bugfix-branch worktree");
assert_eq!(worktree_2.path, worktree_2_directory);
assert_eq!(worktree_2.sha.as_ref(), "fake-sha");

View file

@ -4965,7 +4965,7 @@ mod tests {
.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
state.worktrees.push(git::repository::Worktree {
path: std::path::PathBuf::from("/wt/rosewood"),
ref_name: "refs/heads/rosewood".into(),
ref_name: Some("refs/heads/rosewood".into()),
sha: "abc".into(),
});
})
@ -5026,7 +5026,7 @@ mod tests {
.with_git_state(std::path::Path::new("/project/.git"), true, |state| {
state.worktrees.push(git::repository::Worktree {
path: std::path::PathBuf::from("/wt/rosewood"),
ref_name: "refs/heads/rosewood".into(),
ref_name: Some("refs/heads/rosewood".into()),
sha: "abc".into(),
});
})
@ -5130,12 +5130,12 @@ mod tests {
fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
state.worktrees.push(git::repository::Worktree {
path: std::path::PathBuf::from("/wt-feature-a"),
ref_name: "refs/heads/feature-a".into(),
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
});
state.worktrees.push(git::repository::Worktree {
path: std::path::PathBuf::from("/wt-feature-b"),
ref_name: "refs/heads/feature-b".into(),
ref_name: Some("refs/heads/feature-b".into()),
sha: "bbb".into(),
});
})
@ -5232,7 +5232,7 @@ mod tests {
fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
state.worktrees.push(git::repository::Worktree {
path: std::path::PathBuf::from("/wt-feature-a"),
ref_name: "refs/heads/feature-a".into(),
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
});
})
@ -5348,7 +5348,7 @@ mod tests {
fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
state.worktrees.push(git::repository::Worktree {
path: std::path::PathBuf::from("/wt-feature-a"),
ref_name: "refs/heads/feature-a".into(),
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
});
})
@ -5457,7 +5457,7 @@ mod tests {
fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
state.worktrees.push(git::repository::Worktree {
path: std::path::PathBuf::from("/wt-feature-a"),
ref_name: "refs/heads/feature-a".into(),
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
});
})
@ -5563,7 +5563,7 @@ mod tests {
fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
state.worktrees.push(git::repository::Worktree {
path: std::path::PathBuf::from("/wt-feature-a"),
ref_name: "refs/heads/feature-a".into(),
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
});
})