From e7927edccdf6c8a171e7e5d5a8cf36284273c4b5 Mon Sep 17 00:00:00 2001 From: Albert Bogusz Date: Wed, 1 Jul 2026 15:51:10 +0100 Subject: [PATCH] Support checking out remote HEAD refs (#57648) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments N/A - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Remote `HEAD` refs such as `origin/HEAD` and `upstream/HEAD` are symbolic refs that point to a remote's default branch. These refs can appear in the branch picker, but selecting them shows this error prompt: ![Error Popup](https://github.com/user-attachments/assets/f30f9c9a-8f4e-43d9-8830-282eb17a3572) This happens because the local branch name is directly derived from the selected ref, resulting in an attempted checkout of a local branch named `HEAD`. This change follows up on feedback from #57588, which just filtered remote `HEAD` refs out of the branch picker to prevent the error prompt. As mentioned in [this comment](https://github.com/zed-industries/zed/pull/57588#issuecomment-4533477336), I agree that the preferred behaviour should be to instead allow checking them out - useful for repos with multiple remotes such as an upstream. For example, if `refs/remotes/origin/HEAD` points to `refs/remotes/origin/main`, selecting `origin/HEAD` now behaves like selecting `origin/main`, switching to the corresponding local `main` branch instead of trying to create or check out a branch named `HEAD`. This does not fetch or pull from the remote; it uses the locally known remote refs. Release Notes: - Fixed selecting remote HEAD refs from the branch picker --------- Co-authored-by: Smit Barmase --- crates/git/src/repository.rs | 207 ++++++++++++++++++++++++++++------- 1 file changed, 165 insertions(+), 42 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 93338de75c..f09c13f54a 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -2131,6 +2131,13 @@ impl GitRepository for RealGitRepository { .await .is_ok() { + let name = match git_binary.run(&["symbolic-ref", &remote_ref]).await { + Ok(resolved) => resolved + .strip_prefix("refs/remotes/") + .map(str::to_owned) + .unwrap_or(name), + Err(_) => name, + }; let (_, branch_name) = name.split_once('/').context("Unexpected branch format")?; let local_branch_ref = format!("refs/heads/{branch_name}"); @@ -3934,6 +3941,52 @@ mod tests { git_command(path, ["init", "-b", "main"]); } + fn clone_remote_repository_with_main_and_feature(temp_dir: &Path) -> (PathBuf, PathBuf) { + let remote_directory = temp_dir.join("remote.git"); + let seed_directory = temp_dir.join("seed"); + let clone_directory = temp_dir.join("clone"); + + git_command( + temp_dir, + [ + OsString::from("init"), + OsString::from("--bare"), + OsString::from("-b"), + OsString::from("main"), + remote_directory.as_os_str().into(), + ], + ); + git_init_repo(&seed_directory); + fs::write(seed_directory.join("file.txt"), "main").unwrap(); + git_command(&seed_directory, ["add", "file.txt"]); + git_command(&seed_directory, ["commit", "-m", "initial"]); + git_command(&seed_directory, ["switch", "-c", "feature"]); + fs::write(seed_directory.join("feature.txt"), "feature").unwrap(); + git_command(&seed_directory, ["add", "feature.txt"]); + git_command(&seed_directory, ["commit", "-m", "feature"]); + git_command( + &seed_directory, + [ + OsString::from("remote"), + OsString::from("add"), + OsString::from("origin"), + remote_directory.as_os_str().into(), + ], + ); + git_command(&seed_directory, ["push", "-u", "origin", "main"]); + git_command(&seed_directory, ["push", "-u", "origin", "feature"]); + git_command( + temp_dir, + [ + OsString::from("clone"), + remote_directory.as_os_str().into(), + clone_directory.as_os_str().into(), + ], + ); + + (remote_directory, clone_directory) + } + fn test_commit_envs() -> HashMap { let mut env = checkpoint_author_envs(); env.insert("GIT_ASKPASS".to_string(), "false".to_string()); @@ -4078,50 +4131,11 @@ mod tests { cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); - let remote_dir = temp_dir.path().join("remote.git"); - let seed_dir = temp_dir.path().join("seed"); - let clone_dir = temp_dir.path().join("clone"); - - git_command( - temp_dir.path(), - [ - OsString::from("init"), - OsString::from("--bare"), - OsString::from("-b"), - OsString::from("main"), - remote_dir.as_os_str().into(), - ], - ); - git_init_repo(&seed_dir); - fs::write(seed_dir.join("file.txt"), "main").unwrap(); - git_command(&seed_dir, ["add", "file.txt"]); - git_command(&seed_dir, ["commit", "-m", "initial"]); - git_command(&seed_dir, ["switch", "-c", "feature"]); - fs::write(seed_dir.join("feature.txt"), "feature").unwrap(); - git_command(&seed_dir, ["add", "feature.txt"]); - git_command(&seed_dir, ["commit", "-m", "feature"]); - git_command( - &seed_dir, - [ - OsString::from("remote"), - OsString::from("add"), - OsString::from("origin"), - remote_dir.as_os_str().into(), - ], - ); - git_command(&seed_dir, ["push", "-u", "origin", "main"]); - git_command(&seed_dir, ["push", "-u", "origin", "feature"]); - git_command( - temp_dir.path(), - [ - OsString::from("clone"), - remote_dir.as_os_str().into(), - clone_dir.as_os_str().into(), - ], - ); + let (_remote_directory, clone_directory) = + clone_remote_repository_with_main_and_feature(temp_dir.path()); let repository = RealGitRepository::new( - &clone_dir.join(".git"), + &clone_directory.join(".git"), None, Some("git".into()), cx.executor(), @@ -4184,6 +4198,115 @@ mod tests { ); } + #[gpui::test] + async fn test_change_branch_resolves_remote_head_to_tracking_branch(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let temp_dir = tempfile::tempdir().unwrap(); + let (_remote_directory, clone_directory) = + clone_remote_repository_with_main_and_feature(temp_dir.path()); + + let repository = RealGitRepository::new( + &clone_directory.join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + let git = repository.git_binary_in_worktree().unwrap(); + git.run(&[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/feature", + ]) + .await + .unwrap(); + assert_eq!( + git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]) + .await + .unwrap(), + "refs/remotes/origin/feature" + ); + assert!( + git.run(&["show-ref", "--verify", "--quiet", "refs/heads/feature"]) + .await + .is_err() + ); + + repository + .change_branch("origin/HEAD".to_string()) + .await + .unwrap(); + + let git = repository.git_binary_in_worktree().unwrap(); + assert_eq!( + git.run(&["branch", "--show-current"]).await.unwrap(), + "feature" + ); + assert_eq!( + git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",]) + .await + .unwrap(), + "origin/feature" + ); + } + + #[gpui::test] + async fn test_change_branch_resolves_non_origin_remote_head(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let temp_dir = tempfile::tempdir().unwrap(); + let (remote_directory, clone_directory) = + clone_remote_repository_with_main_and_feature(temp_dir.path()); + + git_command( + &clone_directory, + [ + OsString::from("remote"), + OsString::from("add"), + OsString::from("upstream"), + remote_directory.as_os_str().into(), + ], + ); + git_command(&clone_directory, ["fetch", "upstream"]); + + let repository = RealGitRepository::new( + &clone_directory.join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + let git = repository.git_binary_in_worktree().unwrap(); + git.run(&[ + "symbolic-ref", + "refs/remotes/upstream/HEAD", + "refs/remotes/upstream/main", + ]) + .await + .unwrap(); + git.run(&["checkout", "-b", "scratch"]).await.unwrap(); + + repository + .change_branch("upstream/HEAD".to_string()) + .await + .unwrap(); + + let git = repository.git_binary_in_worktree().unwrap(); + assert_eq!( + git.run(&["branch", "--show-current"]).await.unwrap(), + "main" + ); + assert_eq!( + git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",]) + .await + .unwrap(), + "upstream/main" + ); + } + #[gpui::test] fn test_real_git_repository_new_rejects_malformed_git_file(cx: &mut TestAppContext) { disable_git_global_config();