From 439d31e2d412c14210685ce75cbae9e9e3bef361 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Thu, 18 Sep 2025 14:17:13 -0400 Subject: [PATCH] Add branch rename action to Git panel (#38273) Reopening #35136, cc @launay12u Release Notes: - git: added `git: rename branch` action to rename a branch (`git branch -m`) --------- Co-authored-by: Guillaume Launay Co-authored-by: Peter Tripp --- 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(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 549c788dfac6acbb69fec8c715fb2a31b3674040..b608d0fec65a80057445fb3598102297f445ad4f 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/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> { self.with_state_async(false, move |state| { state diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 73d32ac9e468b57e13fc9bf714bc96d55549167c..2028a0f374578d0c0f35bdc8c80ec09462ab0875 100644 --- a/crates/git/src/git.rs +++ b/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, +} + /// 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"])] diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 10aaca38bbb3f7326e9bae27d4e6b1e9c20bb59a..29e2dab240e83da8d4343a370970ec0cc2256601 100644 --- a/crates/git/src/repository.rs +++ b/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> { let working_directory = self.working_directory(); let git_binary_path = self.git_binary_path.clone(); diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 000b6639b440914f117e30cc3272bf4cc38d8be6..cede717d53b257be2570c4b0c067fb46341c0fc5 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/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, + repo: Entity, +} + +impl RenameBranchModal { + fn new( + current_branch: String, + repo: Entity, + window: &mut Window, + cx: &mut Context, + ) -> 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) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + 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 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) -> 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, +) { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + let current_branch: Option = 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, branch: &Branch, diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 4b9ee462529e980c782c555157e0f1ff34029fb7..a3d777ac774216967b2a5ffab03c72cf51dd9e7d 100644 --- a/crates/project/src/git_store.rs +++ b/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, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + 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, envelope: TypedEnvelope, @@ -4331,6 +4351,36 @@ impl Repository { ) } + pub fn rename_branch( + &mut self, + branch: String, + new_name: String, + ) -> oneshot::Receiver> { + 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>> { let id = self.id; self.send_job(None, move |repo, _cx| async move { diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 3f17f0d0c3483ade36b73e26c7207f6cf667bb63..7004b0c9a0b4aff54434fac6b1f6ecc9be773ed4 100644 --- a/crates/proto/proto/git.proto +++ b/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; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index b20979081187b3dc7350b08b5c07ae700d86e02e..d9cc166c9b77fdd6cb4c876c4b118598b50895b2 100644 --- a/crates/proto/proto/zed.proto +++ b/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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 2985fde4d3ff4357628534f0ca3a5daf5476f813..5359ee983d9ceb01cd11e14c4d6dd491e097ea11 100644 --- a/crates/proto/src/proto.rs +++ b/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,