Add branch rename action to Git panel (#38273)

Cole Miller , Guillaume Launay , and Peter Tripp created

Reopening #35136, cc @launay12u

Release Notes:

- git: added `git: rename branch` action to rename a branch (`git branch
-m`)

---------

Co-authored-by: Guillaume Launay <guillaume.launay@paylead.fr>
Co-authored-by: Peter Tripp <petertripp@gmail.com>

Change summary

crates/fs/src/fake_git_repo.rs  |  15 +++
crates/git/src/git.rs           |  13 +++
crates/git/src/repository.rs    |  21 ++++
crates/git_ui/src/git_ui.rs     | 133 ++++++++++++++++++++++++++++++++++
crates/project/src/git_store.rs |  50 +++++++++++++
crates/proto/proto/git.proto    |   7 +
crates/proto/proto/zed.proto    |   4 
crates/proto/src/proto.rs       |   3 
8 files changed, 238 insertions(+), 8 deletions(-)

Detailed changes

crates/fs/src/fake_git_repo.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{FakeFs, FakeFsEntry, Fs};
-use anyhow::{Context as _, Result};
+use anyhow::{Context as _, Result, bail};
 use collections::{HashMap, HashSet};
 use futures::future::{self, BoxFuture, join_all};
 use git::{
@@ -354,6 +354,19 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
+    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
+        self.with_state_async(true, move |state| {
+            if !state.branches.remove(&branch) {
+                bail!("no such branch: {branch}");
+            }
+            state.branches.insert(new_name.clone());
+            if state.current_branch_name == Some(branch) {
+                state.current_branch_name = Some(new_name);
+            }
+            Ok(())
+        })
+    }
+
     fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result<git::blame::Blame>> {
         self.with_state_async(false, move |state| {
             state

crates/git/src/git.rs 🔗

@@ -11,6 +11,7 @@ pub use crate::remote::*;
 use anyhow::{Context as _, Result};
 pub use git2 as libgit;
 use gpui::{Action, actions};
+pub use repository::RemoteCommandOutput;
 pub use repository::WORK_DIRECTORY_REPO_PATH;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -101,6 +102,18 @@ actions!(
     ]
 );
 
+/// Renames a git branch.
+#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = git)]
+#[serde(deny_unknown_fields)]
+pub struct RenameBranch {
+    /// The branch to rename.
+    ///
+    /// Default: the current branch.
+    #[serde(default)]
+    pub branch: Option<String>,
+}
+
 /// Restores a file to its last committed state, discarding local changes.
 #[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
 #[action(namespace = git, deprecated_aliases = ["editor::RevertFile"])]

crates/git/src/repository.rs 🔗

@@ -346,6 +346,7 @@ pub trait GitRepository: Send + Sync {
 
     fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
     fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
+    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
 
     fn reset(
         &self,
@@ -1095,11 +1096,11 @@ impl GitRepository for RealGitRepository {
                 let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
                 let revision = revision.get();
                 let branch_commit = revision.peel_to_commit()?;
-                let mut branch = repo.branch(branch_name, &branch_commit, false)?;
+                let mut branch = repo.branch(&branch_name, &branch_commit, false)?;
                 branch.set_upstream(Some(&name))?;
                 branch
             } else {
-                anyhow::bail!("Branch not found");
+                anyhow::bail!("Branch '{}' not found", name);
             };
 
             Ok(branch
@@ -1115,7 +1116,6 @@ impl GitRepository for RealGitRepository {
                 GitBinary::new(git_binary_path, working_directory?, executor)
                     .run(&["checkout", &branch])
                     .await?;
-
                 anyhow::Ok(())
             })
             .boxed()
@@ -1133,6 +1133,21 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
+        let git_binary_path = self.git_binary_path.clone();
+        let working_directory = self.working_directory();
+        let executor = self.executor.clone();
+
+        self.executor
+            .spawn(async move {
+                GitBinary::new(git_binary_path, working_directory?, executor)
+                    .run(&["branch", "-m", &branch, &new_name])
+                    .await?;
+                anyhow::Ok(())
+            })
+            .boxed()
+    }
+
     fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();

crates/git_ui/src/git_ui.rs 🔗

@@ -4,20 +4,28 @@ use ::settings::Settings;
 use command_palette_hooks::CommandPaletteFilter;
 use commit_modal::CommitModal;
 use editor::{Editor, actions::DiffClipboardWithSelectionData};
+use ui::{
+    Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, Styled,
+    StyledExt, div, h_flex, rems, v_flex,
+};
+
 mod blame_ui;
+
 use git::{
     repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
     status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
 };
 use git_panel_settings::GitPanelSettings;
 use gpui::{
-    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window,
-    actions,
+    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString,
+    Window, actions,
 };
+use menu::{Cancel, Confirm};
 use onboarding::GitOnboardingModal;
+use project::git_store::Repository;
 use project_diff::ProjectDiff;
 use ui::prelude::*;
-use workspace::{ModalView, Workspace};
+use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
 use zed_actions;
 
 use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
@@ -202,6 +210,9 @@ pub fn init(cx: &mut App) {
         workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
             open_modified_files(workspace, window, cx);
         });
+        workspace.register_action(|workspace, _: &git::RenameBranch, window, cx| {
+            rename_current_branch(workspace, window, cx);
+        });
         workspace.register_action(
             |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
                 if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
@@ -245,6 +256,122 @@ pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
     GitStatusIcon::new(status)
 }
 
+struct RenameBranchModal {
+    current_branch: SharedString,
+    editor: Entity<Editor>,
+    repo: Entity<Repository>,
+}
+
+impl RenameBranchModal {
+    fn new(
+        current_branch: String,
+        repo: Entity<Repository>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_text(current_branch.clone(), window, cx);
+            editor
+        });
+        Self {
+            current_branch: current_branch.into(),
+            editor,
+            repo,
+        }
+    }
+
+    fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        let new_name = self.editor.read(cx).text(cx);
+        if new_name.is_empty() || new_name == self.current_branch.as_ref() {
+            cx.emit(DismissEvent);
+            return;
+        }
+
+        let repo = self.repo.clone();
+        let current_branch = self.current_branch.to_string();
+        cx.spawn(async move |_, cx| {
+            match repo
+                .update(cx, |repo, _| {
+                    repo.rename_branch(current_branch, new_name.clone())
+                })?
+                .await
+            {
+                Ok(Ok(_)) => Ok(()),
+                Ok(Err(error)) => Err(error),
+                Err(_) => Err(anyhow::anyhow!("Operation was canceled")),
+            }
+        })
+        .detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None);
+        cx.emit(DismissEvent);
+    }
+}
+
+impl EventEmitter<DismissEvent> for RenameBranchModal {}
+impl ModalView for RenameBranchModal {}
+impl Focusable for RenameBranchModal {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.editor.focus_handle(cx)
+    }
+}
+
+impl Render for RenameBranchModal {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .key_context("RenameBranchModal")
+            .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::confirm))
+            .elevation_2(cx)
+            .w(rems(34.))
+            .child(
+                h_flex()
+                    .px_3()
+                    .pt_2()
+                    .pb_1()
+                    .w_full()
+                    .gap_1p5()
+                    .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
+                    .child(
+                        Headline::new(format!("Rename Branch ({})", self.current_branch))
+                            .size(HeadlineSize::XSmall),
+                    ),
+            )
+            .child(div().px_3().pb_3().w_full().child(self.editor.clone()))
+    }
+}
+
+fn rename_current_branch(
+    workspace: &mut Workspace,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+        return;
+    };
+    let current_branch: Option<String> = panel.update(cx, |panel, cx| {
+        let repo = panel.active_repository.as_ref()?;
+        let repo = repo.read(cx);
+        repo.branch.as_ref().map(|branch| branch.name().to_string())
+    });
+
+    let Some(current_branch_name) = current_branch else {
+        return;
+    };
+
+    let repo = panel.read(cx).active_repository.clone();
+    let Some(repo) = repo else {
+        return;
+    };
+
+    workspace.toggle_modal(window, cx, |window, cx| {
+        RenameBranchModal::new(current_branch_name, repo, window, cx)
+    });
+}
+
 fn render_remote_button(
     id: impl Into<SharedString>,
     branch: &Branch,

crates/project/src/git_store.rs 🔗

@@ -398,6 +398,7 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_get_default_branch);
         client.add_entity_request_handler(Self::handle_change_branch);
         client.add_entity_request_handler(Self::handle_create_branch);
+        client.add_entity_request_handler(Self::handle_rename_branch);
         client.add_entity_request_handler(Self::handle_git_init);
         client.add_entity_request_handler(Self::handle_push);
         client.add_entity_request_handler(Self::handle_pull);
@@ -1944,6 +1945,25 @@ impl GitStore {
         Ok(proto::Ack {})
     }
 
+    async fn handle_rename_branch(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitRenameBranch>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+        let branch = envelope.payload.branch;
+        let new_name = envelope.payload.new_name;
+
+        repository_handle
+            .update(&mut cx, |repository_handle, _| {
+                repository_handle.rename_branch(branch, new_name)
+            })?
+            .await??;
+
+        Ok(proto::Ack {})
+    }
+
     async fn handle_show(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::GitShow>,
@@ -4331,6 +4351,36 @@ impl Repository {
         )
     }
 
+    pub fn rename_branch(
+        &mut self,
+        branch: String,
+        new_name: String,
+    ) -> oneshot::Receiver<Result<()>> {
+        let id = self.id;
+        self.send_job(
+            Some(format!("git branch -m {branch} {new_name}").into()),
+            move |repo, _cx| async move {
+                match repo {
+                    RepositoryState::Local { backend, .. } => {
+                        backend.rename_branch(branch, new_name).await
+                    }
+                    RepositoryState::Remote { project_id, client } => {
+                        client
+                            .request(proto::GitRenameBranch {
+                                project_id: project_id.0,
+                                repository_id: id.to_proto(),
+                                branch,
+                                new_name,
+                            })
+                            .await?;
+
+                        Ok(())
+                    }
+                }
+            },
+        )
+    }
+
     pub fn check_for_pushed_commits(&mut self) -> oneshot::Receiver<Result<Vec<SharedString>>> {
         let id = self.id;
         self.send_job(None, move |repo, _cx| async move {

crates/proto/proto/git.proto 🔗

@@ -183,6 +183,13 @@ message GitChangeBranch {
     string branch_name = 4;
 }
 
+message GitRenameBranch {
+    uint64 project_id = 1;
+    uint64 repository_id = 2;
+    string branch = 3;
+    string new_name = 4;
+}
+
 message GitDiff {
     uint64 project_id = 1;
     reserved 2;

crates/proto/proto/zed.proto 🔗

@@ -414,7 +414,9 @@ message Envelope {
         NewExternalAgentVersionAvailable new_external_agent_version_available = 377;
 
         StashDrop stash_drop = 378;
-        StashApply stash_apply = 379; // current max
+        StashApply stash_apply = 379;
+
+        GitRenameBranch git_rename_branch = 380; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs 🔗

@@ -300,6 +300,7 @@ messages!(
     (AskPassResponse, Background),
     (GitCreateBranch, Background),
     (GitChangeBranch, Background),
+    (GitRenameBranch, Background),
     (CheckForPushedCommits, Background),
     (CheckForPushedCommitsResponse, Background),
     (GitDiff, Background),
@@ -483,6 +484,7 @@ request_messages!(
     (AskPassRequest, AskPassResponse),
     (GitCreateBranch, Ack),
     (GitChangeBranch, Ack),
+    (GitRenameBranch, Ack),
     (CheckForPushedCommits, CheckForPushedCommitsResponse),
     (GitDiff, GitDiffResponse),
     (GitInit, Ack),
@@ -637,6 +639,7 @@ entity_messages!(
     Pull,
     AskPassRequest,
     GitChangeBranch,
+    GitRenameBranch,
     GitCreateBranch,
     CheckForPushedCommits,
     GitDiff,