From e1631eaf57bd37297f2b2b2642ecb0fa36f075d1 Mon Sep 17 00:00:00 2001 From: Sergio Roman Date: Fri, 3 Jul 2026 21:08:40 +0200 Subject: [PATCH 1/6] git_graph: Add built-in branch and commit actions to the context menu Adds checkout, merge, create branch, and delete branch entries for branch refs, plus detached checkout, cherry-pick, revert, and create branch entries for commits, to the Git Graph context menu. Branch operations use the existing Repository methods; operations without native backend support run as one-shot terminal tasks through the same infrastructure as custom Git commands. Co-Authored-By: Claude Fable 5 --- crates/git_ui/src/branch_picker.rs | 4 +- crates/git_ui/src/git_graph.rs | 518 ++++++++++++++++++++++++++++- docs/src/git.md | 10 + 3 files changed, 528 insertions(+), 4 deletions(-) diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 1cbfcca4364d64..0e145526bfe916 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -659,7 +659,7 @@ enum PickerState { NewBranch, } -fn delete_branch_command(is_remote: bool, branch_name: &str, force: bool) -> String { +pub(crate) fn delete_branch_command(is_remote: bool, branch_name: &str, force: bool) -> String { format!( "branch {} {branch_name}", delete_branch_flag(is_remote, force) @@ -691,7 +691,7 @@ fn unmerged_branch_force_delete_prompt(branch_name: &str) -> String { // Git only reports these cases via localized stderr, so this best-effort check // may miss some locales and fall back to the raw error toast. -fn force_delete_prompt_for_branch_delete_error( +pub(crate) fn force_delete_prompt_for_branch_delete_error( error: &anyhow::Error, branch_name: &str, ) -> Option { diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 384474e21056ae..8fb3a9fbaeea6d 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1,6 +1,8 @@ use crate::{ + branch_picker::{delete_branch_command, force_delete_prompt_for_branch_delete_error}, commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, commit_view::CommitView, + git_panel::show_error_toast, git_status_icon, }; use collections::{BTreeMap, HashMap, IndexSet}; @@ -19,7 +21,7 @@ use git::{ use gpui::{ Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, - Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollStrategy, + Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, PromptLevel, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, @@ -46,7 +48,7 @@ use std::{ sync::{Arc, OnceLock}, time::{Duration, Instant}, }; -use task::{ResolvedTask, TaskContext, TaskVariables, VariableName}; +use task::{ResolvedTask, TaskContext, TaskTemplate, TaskVariables, VariableName}; use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ @@ -1322,6 +1324,107 @@ struct GitGraphContextMenu { _subscription: Subscription, } +struct CreateBranchModal { + editor: Entity, + repository: Entity, + workspace: WeakEntity, + base: SharedString, +} + +impl EventEmitter for CreateBranchModal {} +impl ModalView for CreateBranchModal {} +impl Focusable for CreateBranchModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl CreateBranchModal { + fn new( + repository: Entity, + base: SharedString, + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Branch name", window, cx); + editor + }); + Self { + editor, + repository, + workspace, + base, + } + } + + fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + let branch_name = self.editor.read(cx).text(cx).trim().to_string(); + if branch_name.is_empty() { + return; + } + let base = self.base.clone(); + let workspace = self.workspace.clone(); + let receiver = self.repository.update(cx, |repository, _| { + repository.create_branch(branch_name.clone(), Some(base.to_string())) + }); + cx.spawn(async move |_, cx| { + if let Ok(Err(error)) = receiver.await + && let Some(workspace) = workspace.upgrade() + { + cx.update(|cx| { + show_error_toast( + workspace, + format!("switch -c {branch_name} {base}"), + error, + cx, + ) + }); + } + }) + .detach(); + cx.emit(DismissEvent); + } +} + +impl Render for CreateBranchModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("GitGraphCreateBranchModal") + .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() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child( + Label::new(format!("Create branch from {}", self.base)) + .size(LabelSize::Small), + ), + ) + .child( + div() + .py_2() + .px_3() + .bg(cx.theme().colors().editor_background) + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(self.editor.clone()), + ) + } +} + pub struct GitGraph { focus_handle: FocusHandle, search_state: SearchState, @@ -2461,6 +2564,168 @@ impl GitGraph { .ok(); } + fn schedule_builtin_git_command( + &mut self, + label: String, + args: Vec, + commit_sha: Oid, + ref_name: Option<&str>, + window: &mut Window, + cx: &mut Context, + ) { + let Some(task_context) = self.git_task_context(commit_sha, ref_name, cx) else { + return; + }; + let task_template = TaskTemplate { + label, + command: "git".to_string(), + args, + ..TaskTemplate::default() + }; + let Some(resolved_task) = task_template.resolve_task("git_graph_command", &task_context) + else { + return; + }; + self.schedule_git_task(TaskSourceKind::UserInput, resolved_task, window, cx); + } + + fn checkout_ref(&mut self, ref_name: SharedString, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let workspace = self.workspace.clone(); + let receiver = repository.update(cx, |repository, _| { + repository.change_branch(ref_name.to_string()) + }); + cx.spawn(async move |_, cx| { + if let Ok(Err(error)) = receiver.await + && let Some(workspace) = workspace.upgrade() + { + cx.update(|cx| { + show_error_toast(workspace, format!("switch {ref_name}"), error, cx) + }); + } + }) + .detach(); + } + + fn create_branch_from( + &mut self, + base: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + workspace.update(cx, |workspace, cx| { + let workspace_handle = cx.weak_entity(); + workspace.toggle_modal(window, cx, |window, cx| { + CreateBranchModal::new(repository, base, workspace_handle, window, cx) + }); + }); + } + + fn delete_branch( + &mut self, + branch_name: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |_, cx| { + let answer = cx.update(|window, cx| { + window.prompt( + PromptLevel::Warning, + &format!("Delete branch \"{branch_name}\"?"), + None, + &["Delete", "Cancel"], + cx, + ) + })?; + if answer.await != Ok(0) { + return anyhow::Ok(()); + } + + let scan = repository + .update(cx, |repository, _| repository.branches()) + .await??; + // The branch may have been deleted or renamed since the graph was + // loaded, in which case there is nothing left to delete. + let Some(branch) = scan + .branches + .iter() + .find(|branch| branch.name() == branch_name.as_ref()) + .cloned() + else { + return Ok(()); + }; + if branch.is_head { + return Ok(()); + } + + let is_remote = branch.is_remote(); + let branch_name = branch.name().to_string(); + let initial_result = repository + .update(cx, |repository, _| { + repository.delete_branch(is_remote, branch_name.clone(), false) + }) + .await?; + + let (result, attempted_force) = match initial_result { + Ok(()) => (Ok(()), false), + Err(error) => { + if let Some(prompt_message) = + force_delete_prompt_for_branch_delete_error(&error, &branch_name) + { + let answer = cx.update(|window, cx| { + window.prompt( + PromptLevel::Warning, + &prompt_message, + None, + &["Force Delete", "Cancel"], + cx, + ) + })?; + if answer.await != Ok(0) { + return Ok(()); + } + let retry = repository + .update(cx, |repository, _| { + repository.delete_branch(is_remote, branch_name.clone(), true) + }) + .await?; + (retry, true) + } else { + (Err(error), false) + } + } + }; + + if let Err(error) = result + && let Some(workspace) = workspace.upgrade() + { + cx.update(|_window, cx| { + show_error_toast( + workspace, + delete_branch_command(is_remote, &branch_name, attempted_force), + error, + cx, + ) + })?; + } + + anyhow::Ok(()) + }) + .detach(); + } + fn deploy_entry_context_menu( &mut self, position: Point, @@ -2479,6 +2744,23 @@ impl GitGraph { .map(|task_context| self.git_context_menu_tasks(&task_context, cx)) .unwrap_or_default(); + let head_branch_name = self.get_repository(cx).and_then(|repository| { + repository + .read(cx) + .snapshot() + .branch + .as_ref() + .map(|branch| SharedString::from(branch.name().to_string())) + }); + let is_tag = ref_name.as_ref().is_some_and(|ref_name| { + commit + .data + .tag_names() + .iter() + .any(|tag_name| *tag_name == ref_name.as_ref()) + }); + let is_checked_out = ref_name.is_some() && ref_name == head_branch_name; + let header = match &ref_name { Some(ref_name) => format!("Ref {ref_name}"), None => format!("Commit {sha_short}"), @@ -2556,6 +2838,136 @@ impl GitGraph { } }) }) + .separator() + .map(|mut menu| { + match ref_name.clone().filter(|_| !is_tag) { + Some(branch_name) => { + let checkout_branch = branch_name.clone(); + menu = menu.item( + ContextMenuEntry::new("Check Out Branch") + .disabled(is_checked_out) + .handler(window.handler_for( + &git_graph, + move |this, _window, cx| { + this.checkout_ref(checkout_branch.clone(), cx); + }, + )), + ); + + menu = menu.item(match head_branch_name.clone() { + Some(head_branch) if !is_checked_out => { + let merge_branch = branch_name.clone(); + ContextMenuEntry::new(format!("Merge into {head_branch}")) + .handler(window.handler_for( + &git_graph, + move |this, window, cx| { + this.schedule_builtin_git_command( + format!("git merge {merge_branch}"), + vec!["merge".into(), "$ZED_GIT_REF".into()], + sha, + Some(merge_branch.as_ref()), + window, + cx, + ); + }, + )) + } + _ => ContextMenuEntry::new("Merge into Current Branch") + .disabled(true), + }); + + let create_base = branch_name.clone(); + menu = menu.entry( + "Create Branch from Here…", + None, + window.handler_for(&git_graph, move |this, window, cx| { + this.create_branch_from(create_base.clone(), window, cx); + }), + ); + + let delete_branch = branch_name; + menu = menu.item( + ContextMenuEntry::new("Delete Branch…") + .disabled(is_checked_out) + .handler(window.handler_for( + &git_graph, + move |this, window, cx| { + this.delete_branch(delete_branch.clone(), window, cx); + }, + )), + ); + } + None => { + if let Some(tag_name) = ref_name.clone() { + menu = menu.entry( + "Create Branch from Here…", + None, + window.handler_for(&git_graph, move |this, window, cx| { + this.create_branch_from(tag_name.clone(), window, cx); + }), + ); + } else { + let checkout_label = format!("git checkout {sha_short}"); + menu = menu.entry( + "Check Out Commit", + None, + window.handler_for(&git_graph, move |this, window, cx| { + this.schedule_builtin_git_command( + checkout_label.clone(), + vec!["checkout".into(), "$ZED_GIT_SHA".into()], + sha, + None, + window, + cx, + ); + }), + ); + + let create_base = SharedString::from(sha.to_string()); + menu = menu.entry( + "Create Branch from Here…", + None, + window.handler_for(&git_graph, move |this, window, cx| { + this.create_branch_from(create_base.clone(), window, cx); + }), + ); + + let cherry_pick_label = format!("git cherry-pick {sha_short}"); + menu = menu.entry( + "Cherry-Pick Commit", + None, + window.handler_for(&git_graph, move |this, window, cx| { + this.schedule_builtin_git_command( + cherry_pick_label.clone(), + vec!["cherry-pick".into(), "$ZED_GIT_SHA".into()], + sha, + None, + window, + cx, + ); + }), + ); + + let revert_label = format!("git revert {sha_short}"); + menu = menu.entry( + "Revert Commit", + None, + window.handler_for(&git_graph, move |this, window, cx| { + this.schedule_builtin_git_command( + revert_label.clone(), + vec!["revert".into(), "$ZED_GIT_SHA".into()], + sha, + None, + window, + cx, + ); + }), + ); + } + } + } + menu + }) .map(|mut menu| { menu = menu.separator().header("Custom Commands"); @@ -7012,6 +7424,108 @@ mod tests { ); } + #[gpui::test] + async fn test_builtin_git_command_resolves_sha_and_repository_cwd(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + + let commit_sha = Oid::try_from("abcdef1234567890abcdef1234567890abcdef12") + .expect("commit SHA should be valid"); + fs.set_graph_commits( + Path::new("/project/.git"), + vec![Arc::new(InitialGraphCommitData { + sha: commit_sha, + parents: SmallVec::new(), + ref_names: Vec::new(), + })], + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("project should have an active repository") + }); + let task_inventory = project.read_with(cx, |project, cx| { + project + .task_store() + .read(cx) + .task_inventory() + .cloned() + .expect("project should have a task inventory") + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(&*cx, |multi_workspace, _| { + multi_workspace.workspace().clone() + }); + let workspace_weak = workspace.downgrade(); + + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + None, + window, + cx, + ) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx); + }); + cx.run_until_parked(); + + git_graph.update_in(cx, |git_graph, window, cx| { + git_graph.schedule_builtin_git_command( + format!("git cherry-pick {}", commit_sha.display_short()), + vec!["cherry-pick".into(), "$ZED_GIT_SHA".into()], + commit_sha, + None, + window, + cx, + ); + }); + cx.run_until_parked(); + + let (task_source_kind, resolved_task) = task_inventory.read_with(&*cx, |inventory, _| { + inventory + .last_scheduled_task(None) + .expect("built-in Git command should be scheduled") + }); + + assert!( + matches!(task_source_kind, TaskSourceKind::UserInput), + "built-in Git commands should be scheduled as one-shot tasks" + ); + assert_eq!(resolved_task.resolved_label, "git cherry-pick abcdef1"); + assert_eq!(resolved_task.resolved.command, Some("git".to_string())); + assert_eq!( + resolved_task.resolved.args, + vec![ + "cherry-pick".to_string(), + "abcdef1234567890abcdef1234567890abcdef12".to_string(), + ] + ); + assert_eq!( + resolved_task.resolved.cwd, + Some(Path::new("/project").to_path_buf()) + ); + } + #[test] fn test_ref_name_from_decoration() { assert_eq!( diff --git a/docs/src/git.md b/docs/src/git.md index 68514d44092d0c..45e750dadcee28 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -106,6 +106,16 @@ To view File History: - Right-click on an editor tab and select "View File History" - Use the Command Palette and search for "file history" +## Git Graph + +The Git Graph ({#action git_graph::Open}) visualizes your repository's commit history across branches. Right-clicking a commit or a ref label opens a context menu with common Git operations: + +- On a branch label: check out the branch, merge it into the current branch, create a new branch from it, or delete it. +- On a commit: check out the commit, create a branch from it, cherry-pick it, or revert it. +- On a tag label: create a branch from the tag. + +Merge, cherry-pick, revert, and detached checkouts run as tasks in the terminal, so their output is visible and any conflicts can be resolved from there. You can also add your own entries to this menu with [custom Git commands](./tasks.md#custom-git-commands). + ## Fetch, Push, and Pull Fetch, push, or pull from your Git repository in Zed via the buttons available on the Git Panel or via the Command Palette by looking at the respective actions: {#action git::Fetch}, {#action git::Push}, and {#action git::Pull}. From cd515277eac552bbc911d6c5e7a94fb3b09dfad3 Mon Sep 17 00:00:00 2001 From: Sergio Roman Date: Fri, 3 Jul 2026 21:41:07 +0200 Subject: [PATCH 2/6] Make git graph merge, cherry-pick, revert, and checkout-commit native Replaces the terminal-task implementation with native GitRepository operations, including proto messages and collab forwarding so they work in shared and remote projects. Co-Authored-By: Claude Fable 5 --- crates/collab/src/rpc.rs | 4 + crates/fs/src/fake_git_repo.rs | 19 ++++ crates/git/src/repository.rs | 124 ++++++++++++++++++++ crates/git_ui/src/git_graph.rs | 196 ++++++++++++++------------------ crates/project/src/git_store.rs | 180 +++++++++++++++++++++++++++++ crates/proto/proto/git.proto | 24 ++++ crates/proto/proto/zed.proto | 7 +- crates/proto/src/proto.rs | 12 ++ docs/src/git.md | 2 +- 9 files changed, 458 insertions(+), 110 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 431350971ce590..95a32bf848f103 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -491,6 +491,10 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_read_only_project_request::) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 45ee57b35f5ce6..8f2bd1ba004f88 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -930,6 +930,25 @@ impl GitRepository for FakeGitRepository { }) } + fn merge(&self, _ref_name: String) -> BoxFuture<'_, Result<()>> { + async { Ok(()) }.boxed() + } + + fn cherry_pick(&self, _commit: String) -> BoxFuture<'_, Result<()>> { + async { Ok(()) }.boxed() + } + + fn revert(&self, _commit: String) -> BoxFuture<'_, Result<()>> { + async { Ok(()) }.boxed() + } + + fn checkout_commit(&self, _commit: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, |state| { + state.current_branch_name = None; + Ok(()) + }) + } + fn delete_branch( &self, _is_remote: bool, diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index f09c13f54ab26c..37eda277c760ab 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -867,6 +867,18 @@ pub trait GitRepository: Send + Sync { force: bool, ) -> BoxFuture<'_, Result<()>>; + /// Merges the given commit-ish into the current branch. + fn merge(&self, ref_name: String) -> BoxFuture<'_, Result<()>>; + + /// Cherry-picks the given commit onto the current branch. + fn cherry_pick(&self, commit: String) -> BoxFuture<'_, Result<()>>; + + /// Creates a new commit reverting the given commit. + fn revert(&self, commit: String) -> BoxFuture<'_, Result<()>>; + + /// Checks out the given commit-ish with a detached HEAD. + fn checkout_commit(&self, commit: String) -> BoxFuture<'_, Result<()>>; + fn worktrees(&self) -> BoxFuture<'_, Result>>; /// Returns the creation time of a linked worktree's git metadata @@ -2201,6 +2213,54 @@ impl GitRepository for RealGitRepository { .boxed() } + fn merge(&self, ref_name: String) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary_in_worktree(); + + self.executor + .spawn(async move { + let git_binary = git_binary?; + git_binary.run(&["merge", "--no-edit", &ref_name]).await?; + anyhow::Ok(()) + }) + .boxed() + } + + fn cherry_pick(&self, commit: String) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary_in_worktree(); + + self.executor + .spawn(async move { + let git_binary = git_binary?; + git_binary.run(&["cherry-pick", &commit]).await?; + anyhow::Ok(()) + }) + .boxed() + } + + fn revert(&self, commit: String) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary_in_worktree(); + + self.executor + .spawn(async move { + let git_binary = git_binary?; + git_binary.run(&["revert", "--no-edit", &commit]).await?; + anyhow::Ok(()) + }) + .boxed() + } + + fn checkout_commit(&self, commit: String) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary_in_worktree(); + + self.executor + .spawn(async move { + let git_binary = git_binary?; + git_binary.run(&["checkout", "--detach", &commit]).await?; + anyhow::Ok(()) + }) + .boxed() + } + fn delete_branch( &self, is_remote: bool, @@ -4334,6 +4394,70 @@ mod tests { ); } + #[gpui::test] + async fn test_merge_cherry_pick_revert_and_checkout_commit(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git_init_repo(repo_dir.path()); + git_command(repo_dir.path(), ["config", "user.name", "test"]); + git_command(repo_dir.path(), ["config", "user.email", "test@zed.dev"]); + fs::write(repo_dir.path().join("file.txt"), "one\n").unwrap(); + git_command(repo_dir.path(), ["add", "file.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "one"]); + + git_command(repo_dir.path(), ["switch", "-c", "feature"]); + fs::write(repo_dir.path().join("feature.txt"), "feature\n").unwrap(); + git_command(repo_dir.path(), ["add", "feature.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "feature"]); + git_command(repo_dir.path(), ["switch", "main"]); + + let repository = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + let git = repository.git_binary_in_worktree().unwrap(); + + repository.merge("feature".to_string()).await.unwrap(); + assert!( + repo_dir.path().join("feature.txt").exists(), + "merging the feature branch should bring its files into main" + ); + + git_command(repo_dir.path(), ["switch", "-c", "side"]); + fs::write(repo_dir.path().join("side.txt"), "side\n").unwrap(); + git_command(repo_dir.path(), ["add", "side.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "side"]); + let side_sha = git.run(&["rev-parse", "HEAD"]).await.unwrap(); + git_command(repo_dir.path(), ["switch", "main"]); + + repository.cherry_pick(side_sha).await.unwrap(); + assert!( + repo_dir.path().join("side.txt").exists(), + "cherry-picking should apply the side commit onto main" + ); + + let picked_sha = git.run(&["rev-parse", "HEAD"]).await.unwrap(); + repository.revert(picked_sha).await.unwrap(); + assert!( + !repo_dir.path().join("side.txt").exists(), + "reverting the cherry-picked commit should remove its files" + ); + + let first_sha = git.run(&["rev-list", "--max-parents=0", "HEAD"]).await.unwrap(); + repository.checkout_commit(first_sha.clone()).await.unwrap(); + assert_eq!( + git.run(&["branch", "--show-current"]).await.unwrap(), + "", + "HEAD should be detached after checking out a commit" + ); + assert_eq!(git.run(&["rev-parse", "HEAD"]).await.unwrap(), first_sha); + } + #[test] fn test_initial_graph_commit_data_tag_names() { let commit = InitialGraphCommitData { diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 8fb3a9fbaeea6d..d42e36bf0e821f 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -8,6 +8,7 @@ use crate::{ use collections::{BTreeMap, HashMap, IndexSet}; use editor::Editor; use file_icons::FileIcons; +use futures::channel::oneshot; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, commit::ParsedCommitMessage, @@ -48,7 +49,7 @@ use std::{ sync::{Arc, OnceLock}, time::{Duration, Instant}, }; -use task::{ResolvedTask, TaskContext, TaskTemplate, TaskVariables, VariableName}; +use task::{ResolvedTask, TaskContext, TaskVariables, VariableName}; use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ @@ -2564,49 +2565,83 @@ impl GitGraph { .ok(); } - fn schedule_builtin_git_command( - &mut self, - label: String, - args: Vec, - commit_sha: Oid, - ref_name: Option<&str>, - window: &mut Window, + /// Detaches a repository operation, surfacing its error (if any) as a + /// toast labeled with the git subcommand that failed. + fn detach_op_with_error_toast( + &self, + command_label: String, + receiver: oneshot::Receiver>, cx: &mut Context, ) { - let Some(task_context) = self.git_task_context(commit_sha, ref_name, cx) else { + let workspace = self.workspace.clone(); + cx.spawn(async move |_, cx| { + if let Ok(Err(error)) = receiver.await + && let Some(workspace) = workspace.upgrade() + { + cx.update(|cx| show_error_toast(workspace, command_label, error, cx)); + } + }) + .detach(); + } + + fn checkout_ref(&mut self, ref_name: SharedString, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let receiver = repository.update(cx, |repository, _| { + repository.change_branch(ref_name.to_string()) + }); + self.detach_op_with_error_toast(format!("switch {ref_name}"), receiver, cx); + } + + fn merge_ref(&mut self, ref_name: SharedString, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { return; }; - let task_template = TaskTemplate { - label, - command: "git".to_string(), - args, - ..TaskTemplate::default() + let receiver = + repository.update(cx, |repository, _| repository.merge(ref_name.to_string())); + self.detach_op_with_error_toast(format!("merge {ref_name}"), receiver, cx); + } + + fn cherry_pick_commit(&mut self, commit_sha: Oid, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; }; - let Some(resolved_task) = task_template.resolve_task("git_graph_command", &task_context) - else { + let receiver = repository.update(cx, |repository, _| { + repository.cherry_pick(commit_sha.to_string()) + }); + self.detach_op_with_error_toast( + format!("cherry-pick {}", commit_sha.display_short()), + receiver, + cx, + ); + } + + fn revert_commit(&mut self, commit_sha: Oid, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { return; }; - self.schedule_git_task(TaskSourceKind::UserInput, resolved_task, window, cx); + let receiver = + repository.update(cx, |repository, _| repository.revert(commit_sha.to_string())); + self.detach_op_with_error_toast( + format!("revert {}", commit_sha.display_short()), + receiver, + cx, + ); } - fn checkout_ref(&mut self, ref_name: SharedString, cx: &mut Context) { + fn checkout_commit(&mut self, commit_sha: Oid, cx: &mut Context) { let Some(repository) = self.get_repository(cx) else { return; }; - let workspace = self.workspace.clone(); let receiver = repository.update(cx, |repository, _| { - repository.change_branch(ref_name.to_string()) + repository.checkout_commit(commit_sha.to_string()) }); - cx.spawn(async move |_, cx| { - if let Ok(Err(error)) = receiver.await - && let Some(workspace) = workspace.upgrade() - { - cx.update(|cx| { - show_error_toast(workspace, format!("switch {ref_name}"), error, cx) - }); - } - }) - .detach(); + self.detach_op_with_error_toast( + format!("checkout --detach {}", commit_sha.display_short()), + receiver, + cx, + ); } fn create_branch_from( @@ -2860,15 +2895,8 @@ impl GitGraph { ContextMenuEntry::new(format!("Merge into {head_branch}")) .handler(window.handler_for( &git_graph, - move |this, window, cx| { - this.schedule_builtin_git_command( - format!("git merge {merge_branch}"), - vec!["merge".into(), "$ZED_GIT_REF".into()], - sha, - Some(merge_branch.as_ref()), - window, - cx, - ); + move |this, _window, cx| { + this.merge_ref(merge_branch.clone(), cx); }, )) } @@ -2907,19 +2935,11 @@ impl GitGraph { }), ); } else { - let checkout_label = format!("git checkout {sha_short}"); menu = menu.entry( "Check Out Commit", None, - window.handler_for(&git_graph, move |this, window, cx| { - this.schedule_builtin_git_command( - checkout_label.clone(), - vec!["checkout".into(), "$ZED_GIT_SHA".into()], - sha, - None, - window, - cx, - ); + window.handler_for(&git_graph, move |this, _window, cx| { + this.checkout_commit(sha, cx); }), ); @@ -2932,35 +2952,19 @@ impl GitGraph { }), ); - let cherry_pick_label = format!("git cherry-pick {sha_short}"); menu = menu.entry( "Cherry-Pick Commit", None, - window.handler_for(&git_graph, move |this, window, cx| { - this.schedule_builtin_git_command( - cherry_pick_label.clone(), - vec!["cherry-pick".into(), "$ZED_GIT_SHA".into()], - sha, - None, - window, - cx, - ); + window.handler_for(&git_graph, move |this, _window, cx| { + this.cherry_pick_commit(sha, cx); }), ); - let revert_label = format!("git revert {sha_short}"); menu = menu.entry( "Revert Commit", None, - window.handler_for(&git_graph, move |this, window, cx| { - this.schedule_builtin_git_command( - revert_label.clone(), - vec!["revert".into(), "$ZED_GIT_SHA".into()], - sha, - None, - window, - cx, - ); + window.handler_for(&git_graph, move |this, _window, cx| { + this.revert_commit(sha, cx); }), ); } @@ -7425,7 +7429,7 @@ mod tests { } #[gpui::test] - async fn test_builtin_git_command_resolves_sha_and_repository_cwd(cx: &mut TestAppContext) { + async fn test_checkout_commit_detaches_head(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); @@ -7437,6 +7441,7 @@ mod tests { }), ) .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); let commit_sha = Oid::try_from("abcdef1234567890abcdef1234567890abcdef12") .expect("commit SHA should be valid"); @@ -7457,14 +7462,6 @@ mod tests { .active_repository(cx) .expect("project should have an active repository") }); - let task_inventory = project.read_with(cx, |project, cx| { - project - .task_store() - .read(cx) - .task_inventory() - .cloned() - .expect("project should have a task inventory") - }); let (multi_workspace, cx) = cx.add_window_view(|window, cx| { workspace::MultiWorkspace::test_new(project.clone(), window, cx) @@ -7489,41 +7486,24 @@ mod tests { }); cx.run_until_parked(); - git_graph.update_in(cx, |git_graph, window, cx| { - git_graph.schedule_builtin_git_command( - format!("git cherry-pick {}", commit_sha.display_short()), - vec!["cherry-pick".into(), "$ZED_GIT_SHA".into()], - commit_sha, - None, - window, - cx, + repository.read_with(&*cx, |repository, _| { + assert!( + repository.snapshot().branch.is_some(), + "a branch should be checked out initially" ); }); - cx.run_until_parked(); - let (task_source_kind, resolved_task) = task_inventory.read_with(&*cx, |inventory, _| { - inventory - .last_scheduled_task(None) - .expect("built-in Git command should be scheduled") + git_graph.update_in(cx, |git_graph, _window, cx| { + git_graph.checkout_commit(commit_sha, cx); }); + cx.run_until_parked(); - assert!( - matches!(task_source_kind, TaskSourceKind::UserInput), - "built-in Git commands should be scheduled as one-shot tasks" - ); - assert_eq!(resolved_task.resolved_label, "git cherry-pick abcdef1"); - assert_eq!(resolved_task.resolved.command, Some("git".to_string())); - assert_eq!( - resolved_task.resolved.args, - vec![ - "cherry-pick".to_string(), - "abcdef1234567890abcdef1234567890abcdef12".to_string(), - ] - ); - assert_eq!( - resolved_task.resolved.cwd, - Some(Path::new("/project").to_path_buf()) - ); + repository.read_with(&*cx, |repository, _| { + assert!( + repository.snapshot().branch.is_none(), + "HEAD should be detached after checking out a commit" + ); + }); } #[test] diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 778743163d6516..c57c113960ebd8 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -658,6 +658,10 @@ impl GitStore { 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_merge); + client.add_entity_request_handler(Self::handle_cherry_pick); + client.add_entity_request_handler(Self::handle_revert); + client.add_entity_request_handler(Self::handle_checkout_commit); client.add_entity_request_handler(Self::handle_create_remote); client.add_entity_request_handler(Self::handle_remove_remote); client.add_entity_request_handler(Self::handle_delete_branch); @@ -3197,6 +3201,78 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_git_merge( + 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 ref_name = envelope.payload.ref_name; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.merge(ref_name) + }) + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_cherry_pick( + 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 commit = envelope.payload.commit; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.cherry_pick(commit) + }) + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_revert( + 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 commit = envelope.payload.commit; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.revert(commit) + }) + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_checkout_commit( + 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 commit = envelope.payload.commit; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.checkout_commit(commit) + }) + .await??; + + Ok(proto::Ack {}) + } + async fn handle_rename_branch( this: Entity, envelope: TypedEnvelope, @@ -8023,6 +8099,110 @@ impl Repository { ) } + pub fn merge(&mut self, ref_name: String) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + "merge", + Some(format!("git merge --no-edit {ref_name}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.merge(ref_name).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitMerge { + project_id: project_id.0, + repository_id: id.to_proto(), + ref_name, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + + pub fn cherry_pick(&mut self, commit: String) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + "cherry_pick", + Some(format!("git cherry-pick {commit}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.cherry_pick(commit).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitCherryPick { + project_id: project_id.0, + repository_id: id.to_proto(), + commit, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + + pub fn revert(&mut self, commit: String) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + "revert", + Some(format!("git revert --no-edit {commit}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.revert(commit).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRevert { + project_id: project_id.0, + repository_id: id.to_proto(), + commit, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + + pub fn checkout_commit(&mut self, commit: String) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + "checkout_commit", + Some(format!("git checkout --detach {commit}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.checkout_commit(commit).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitCheckoutCommit { + project_id: project_id.0, + repository_id: id.to_proto(), + commit, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + pub fn delete_branch( &mut self, is_remote: bool, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index c4c838042c0094..1b19123b46a380 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -199,6 +199,30 @@ message GitRenameBranch { string new_name = 4; } +message GitMerge { + uint64 project_id = 1; + uint64 repository_id = 2; + string ref_name = 3; +} + +message GitCherryPick { + uint64 project_id = 1; + uint64 repository_id = 2; + string commit = 3; +} + +message GitRevert { + uint64 project_id = 1; + uint64 repository_id = 2; + string commit = 3; +} + +message GitCheckoutCommit { + uint64 project_id = 1; + uint64 repository_id = 2; + string commit = 3; +} + message GitCreateRemote { uint64 project_id = 1; uint64 repository_id = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 2a196e86e86c18..5c54a20bcbd82d 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -489,7 +489,12 @@ message Envelope { GitWorktreeCreatedAtResponse git_worktree_created_at_response = 458; TelemetryEvent telemetry_event = 459; ResolveCodeAction resolve_code_action = 460; - ResolveCodeActionResponse resolve_code_action_response = 461; // current max + ResolveCodeActionResponse resolve_code_action_response = 461; + + GitMerge git_merge = 462; + GitCherryPick git_cherry_pick = 463; + GitRevert git_revert = 464; + GitCheckoutCommit git_checkout_commit = 465; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 3fbd23527579e2..e8b4e0fb499959 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -323,6 +323,10 @@ messages!( (GitCreateBranch, Background), (GitChangeBranch, Background), (GitRenameBranch, Background), + (GitMerge, Background), + (GitCherryPick, Background), + (GitRevert, Background), + (GitCheckoutCommit, Background), (TrustWorktrees, Background), (RestrictWorktrees, Background), (CheckForPushedCommits, Background), @@ -561,6 +565,10 @@ request_messages!( (GitCreateBranch, Ack), (GitChangeBranch, Ack), (GitRenameBranch, Ack), + (GitMerge, Ack), + (GitCherryPick, Ack), + (GitRevert, Ack), + (GitCheckoutCommit, Ack), (CheckForPushedCommits, CheckForPushedCommitsResponse), (GitDiff, GitDiffResponse), (GitInit, Ack), @@ -759,6 +767,10 @@ entity_messages!( GitChangeBranch, GitRenameBranch, GitCreateBranch, + GitMerge, + GitCherryPick, + GitRevert, + GitCheckoutCommit, GitCreateRemote, GitRemoveRemote, CheckForPushedCommits, diff --git a/docs/src/git.md b/docs/src/git.md index 45e750dadcee28..98a9523199df39 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -114,7 +114,7 @@ The Git Graph ({#action git_graph::Open}) visualizes your repository's commit hi - On a commit: check out the commit, create a branch from it, cherry-pick it, or revert it. - On a tag label: create a branch from the tag. -Merge, cherry-pick, revert, and detached checkouts run as tasks in the terminal, so their output is visible and any conflicts can be resolved from there. You can also add your own entries to this menu with [custom Git commands](./tasks.md#custom-git-commands). +If an operation fails — for example, a merge or cherry-pick with conflicts — Zed shows the error, and any conflicts can be resolved from the Git Panel as usual. You can also add your own entries to this menu with [custom Git commands](./tasks.md#custom-git-commands). ## Fetch, Push, and Pull From 91b09a38ba2a79e15542bec2af6dcef41479f9b8 Mon Sep 17 00:00:00 2001 From: Sergio Roman Date: Fri, 3 Jul 2026 21:55:36 +0200 Subject: [PATCH 3/6] Add tag actions and squash merge to the git graph context menu Adds Create Tag on branches and commits, Check Out Tag and Delete Tag on tag labels, and a Squash Merge variant alongside the regular merge, with the corresponding native backend operations and proto messages. Co-Authored-By: Claude Fable 5 --- crates/collab/src/rpc.rs | 2 + crates/fs/src/fake_git_repo.rs | 10 +- crates/git/src/repository.rs | 80 ++++++++++- crates/git_ui/src/git_graph.rs | 235 +++++++++++++++++++++++++------- crates/project/src/git_store.rs | 107 ++++++++++++++- crates/proto/proto/git.proto | 14 ++ crates/proto/proto/zed.proto | 4 +- crates/proto/src/proto.rs | 6 + docs/src/git.md | 6 +- 9 files changed, 403 insertions(+), 61 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 95a32bf848f103..893ad5f4674426 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -495,6 +495,8 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_read_only_project_request::) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 8f2bd1ba004f88..d019355712962d 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -930,7 +930,7 @@ impl GitRepository for FakeGitRepository { }) } - fn merge(&self, _ref_name: String) -> BoxFuture<'_, Result<()>> { + fn merge(&self, _ref_name: String, _squash: bool) -> BoxFuture<'_, Result<()>> { async { Ok(()) }.boxed() } @@ -949,6 +949,14 @@ impl GitRepository for FakeGitRepository { }) } + fn create_tag(&self, _name: String, _target: String) -> BoxFuture<'_, Result<()>> { + async { Ok(()) }.boxed() + } + + fn delete_tag(&self, _name: String) -> BoxFuture<'_, Result<()>> { + async { Ok(()) }.boxed() + } + fn delete_branch( &self, _is_remote: bool, diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 37eda277c760ab..eb1987e9f72923 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -867,8 +867,9 @@ pub trait GitRepository: Send + Sync { force: bool, ) -> BoxFuture<'_, Result<()>>; - /// Merges the given commit-ish into the current branch. - fn merge(&self, ref_name: String) -> BoxFuture<'_, Result<()>>; + /// Merges the given commit-ish into the current branch. A squash merge + /// stages the changes without committing them. + fn merge(&self, ref_name: String, squash: bool) -> BoxFuture<'_, Result<()>>; /// Cherry-picks the given commit onto the current branch. fn cherry_pick(&self, commit: String) -> BoxFuture<'_, Result<()>>; @@ -879,6 +880,12 @@ pub trait GitRepository: Send + Sync { /// Checks out the given commit-ish with a detached HEAD. fn checkout_commit(&self, commit: String) -> BoxFuture<'_, Result<()>>; + /// Creates a lightweight tag pointing at the given commit-ish. + fn create_tag(&self, name: String, target: String) -> BoxFuture<'_, Result<()>>; + + /// Deletes the given local tag. + fn delete_tag(&self, name: String) -> BoxFuture<'_, Result<()>>; + fn worktrees(&self) -> BoxFuture<'_, Result>>; /// Returns the creation time of a linked worktree's git metadata @@ -2213,13 +2220,14 @@ impl GitRepository for RealGitRepository { .boxed() } - fn merge(&self, ref_name: String) -> BoxFuture<'_, Result<()>> { + fn merge(&self, ref_name: String, squash: bool) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { let git_binary = git_binary?; - git_binary.run(&["merge", "--no-edit", &ref_name]).await?; + let flag = if squash { "--squash" } else { "--no-edit" }; + git_binary.run(&["merge", flag, &ref_name]).await?; anyhow::Ok(()) }) .boxed() @@ -2261,6 +2269,30 @@ impl GitRepository for RealGitRepository { .boxed() } + fn create_tag(&self, name: String, target: String) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary_in_worktree(); + + self.executor + .spawn(async move { + let git_binary = git_binary?; + git_binary.run(&["tag", &name, &target]).await?; + anyhow::Ok(()) + }) + .boxed() + } + + fn delete_tag(&self, name: String) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary_in_worktree(); + + self.executor + .spawn(async move { + let git_binary = git_binary?; + git_binary.run(&["tag", "-d", &name]).await?; + anyhow::Ok(()) + }) + .boxed() + } + fn delete_branch( &self, is_remote: bool, @@ -4422,12 +4454,45 @@ mod tests { .unwrap(); let git = repository.git_binary_in_worktree().unwrap(); - repository.merge("feature".to_string()).await.unwrap(); + repository + .merge("feature".to_string(), false) + .await + .unwrap(); assert!( repo_dir.path().join("feature.txt").exists(), "merging the feature branch should bring its files into main" ); + git_command(repo_dir.path(), ["switch", "-c", "squashed"]); + fs::write(repo_dir.path().join("squashed.txt"), "squashed\n").unwrap(); + git_command(repo_dir.path(), ["add", "squashed.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "squashed"]); + git_command(repo_dir.path(), ["switch", "main"]); + + repository + .merge("squashed".to_string(), true) + .await + .unwrap(); + assert!( + repo_dir.path().join("squashed.txt").exists(), + "squash merging should bring the branch's files into the working tree" + ); + let staged = git.run(&["diff", "--cached", "--name-only"]).await.unwrap(); + assert!( + staged.contains("squashed.txt"), + "squash merging should stage the changes without committing them" + ); + git_command(repo_dir.path(), ["commit", "-m", "squash feature"]); + + repository + .create_tag("v1.0".to_string(), "HEAD".to_string()) + .await + .unwrap(); + assert_eq!(git.run(&["tag", "-l", "v1.0"]).await.unwrap(), "v1.0"); + + repository.delete_tag("v1.0".to_string()).await.unwrap(); + assert_eq!(git.run(&["tag", "-l", "v1.0"]).await.unwrap(), ""); + git_command(repo_dir.path(), ["switch", "-c", "side"]); fs::write(repo_dir.path().join("side.txt"), "side\n").unwrap(); git_command(repo_dir.path(), ["add", "side.txt"]); @@ -4448,7 +4513,10 @@ mod tests { "reverting the cherry-picked commit should remove its files" ); - let first_sha = git.run(&["rev-list", "--max-parents=0", "HEAD"]).await.unwrap(); + let first_sha = git + .run(&["rev-list", "--max-parents=0", "HEAD"]) + .await + .unwrap(); repository.checkout_commit(first_sha.clone()).await.unwrap(); assert_eq!( git.run(&["branch", "--show-current"]).await.unwrap(), diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index d42e36bf0e821f..5582b9fc911b7f 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1325,38 +1325,51 @@ struct GitGraphContextMenu { _subscription: Subscription, } -struct CreateBranchModal { +#[derive(Clone, Copy, PartialEq)] +enum CreateRefKind { + Branch, + Tag, +} + +struct CreateRefModal { editor: Entity, repository: Entity, workspace: WeakEntity, + kind: CreateRefKind, base: SharedString, } -impl EventEmitter for CreateBranchModal {} -impl ModalView for CreateBranchModal {} -impl Focusable for CreateBranchModal { +impl EventEmitter for CreateRefModal {} +impl ModalView for CreateRefModal {} +impl Focusable for CreateRefModal { fn focus_handle(&self, cx: &App) -> FocusHandle { self.editor.focus_handle(cx) } } -impl CreateBranchModal { +impl CreateRefModal { fn new( repository: Entity, + kind: CreateRefKind, base: SharedString, workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { + let placeholder = match kind { + CreateRefKind::Branch => "Branch name", + CreateRefKind::Tag => "Tag name", + }; let editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Branch name", window, cx); + editor.set_placeholder_text(placeholder, window, cx); editor }); Self { editor, repository, workspace, + kind, base, } } @@ -1366,27 +1379,31 @@ impl CreateBranchModal { } fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - let branch_name = self.editor.read(cx).text(cx).trim().to_string(); - if branch_name.is_empty() { + let name = self.editor.read(cx).text(cx).trim().to_string(); + if name.is_empty() { return; } let base = self.base.clone(); let workspace = self.workspace.clone(); - let receiver = self.repository.update(cx, |repository, _| { - repository.create_branch(branch_name.clone(), Some(base.to_string())) - }); + let (receiver, command_label) = match self.kind { + CreateRefKind::Branch => ( + self.repository.update(cx, |repository, _| { + repository.create_branch(name.clone(), Some(base.to_string())) + }), + format!("switch -c {name} {base}"), + ), + CreateRefKind::Tag => ( + self.repository.update(cx, |repository, _| { + repository.create_tag(name.clone(), base.to_string()) + }), + format!("tag {name} {base}"), + ), + }; cx.spawn(async move |_, cx| { if let Ok(Err(error)) = receiver.await && let Some(workspace) = workspace.upgrade() { - cx.update(|cx| { - show_error_toast( - workspace, - format!("switch -c {branch_name} {base}"), - error, - cx, - ) - }); + cx.update(|cx| show_error_toast(workspace, command_label, error, cx)); } }) .detach(); @@ -1394,10 +1411,17 @@ impl CreateBranchModal { } } -impl Render for CreateBranchModal { +impl Render for CreateRefModal { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (icon, title) = match self.kind { + CreateRefKind::Branch => ( + IconName::GitBranch, + format!("Create branch from {}", self.base), + ), + CreateRefKind::Tag => (IconName::Bookmark, format!("Create tag at {}", self.base)), + }; v_flex() - .key_context("GitGraphCreateBranchModal") + .key_context("GitGraphCreateRefModal") .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) .elevation_2(cx) @@ -1408,11 +1432,8 @@ impl Render for CreateBranchModal { .pt_2() .pb_1() .gap_1p5() - .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) - .child( - Label::new(format!("Create branch from {}", self.base)) - .size(LabelSize::Small), - ), + .child(Icon::new(icon).size(IconSize::XSmall)) + .child(Label::new(title).size(LabelSize::Small)), ) .child( div() @@ -2594,13 +2615,62 @@ impl GitGraph { self.detach_op_with_error_toast(format!("switch {ref_name}"), receiver, cx); } - fn merge_ref(&mut self, ref_name: SharedString, cx: &mut Context) { + fn merge_ref(&mut self, ref_name: SharedString, squash: bool, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let receiver = repository.update(cx, |repository, _| { + repository.merge(ref_name.to_string(), squash) + }); + let flag = if squash { "--squash " } else { "" }; + self.detach_op_with_error_toast(format!("merge {flag}{ref_name}"), receiver, cx); + } + + fn checkout_tag(&mut self, tag_name: SharedString, cx: &mut Context) { let Some(repository) = self.get_repository(cx) else { return; }; - let receiver = - repository.update(cx, |repository, _| repository.merge(ref_name.to_string())); - self.detach_op_with_error_toast(format!("merge {ref_name}"), receiver, cx); + let receiver = repository.update(cx, |repository, _| { + repository.checkout_commit(tag_name.to_string()) + }); + self.detach_op_with_error_toast(format!("checkout --detach {tag_name}"), receiver, cx); + } + + fn delete_tag(&mut self, tag_name: SharedString, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |_, cx| { + let answer = cx.update(|window, cx| { + window.prompt( + PromptLevel::Warning, + &format!("Delete tag \"{tag_name}\"?"), + None, + &["Delete", "Cancel"], + cx, + ) + })?; + if answer.await != Ok(0) { + return anyhow::Ok(()); + } + + let result = repository + .update(cx, |repository, _| { + repository.delete_tag(tag_name.to_string()) + }) + .await?; + if let Err(error) = result + && let Some(workspace) = workspace.upgrade() + { + cx.update(|_window, cx| { + show_error_toast(workspace, format!("tag -d {tag_name}"), error, cx) + })?; + } + + anyhow::Ok(()) + }) + .detach(); } fn cherry_pick_commit(&mut self, commit_sha: Oid, cx: &mut Context) { @@ -2621,8 +2691,9 @@ impl GitGraph { let Some(repository) = self.get_repository(cx) else { return; }; - let receiver = - repository.update(cx, |repository, _| repository.revert(commit_sha.to_string())); + let receiver = repository.update(cx, |repository, _| { + repository.revert(commit_sha.to_string()) + }); self.detach_op_with_error_toast( format!("revert {}", commit_sha.display_short()), receiver, @@ -2644,8 +2715,9 @@ impl GitGraph { ); } - fn create_branch_from( + fn create_ref_from( &mut self, + kind: CreateRefKind, base: SharedString, window: &mut Window, cx: &mut Context, @@ -2659,7 +2731,7 @@ impl GitGraph { workspace.update(cx, |workspace, cx| { let workspace_handle = cx.weak_entity(); workspace.toggle_modal(window, cx, |window, cx| { - CreateBranchModal::new(repository, base, workspace_handle, window, cx) + CreateRefModal::new(repository, kind, base, workspace_handle, window, cx) }); }); } @@ -2889,27 +2961,56 @@ impl GitGraph { )), ); - menu = menu.item(match head_branch_name.clone() { - Some(head_branch) if !is_checked_out => { - let merge_branch = branch_name.clone(); - ContextMenuEntry::new(format!("Merge into {head_branch}")) - .handler(window.handler_for( + for squash in [false, true] { + menu = menu.item(match head_branch_name.clone() { + Some(head_branch) if !is_checked_out => { + let merge_branch = branch_name.clone(); + let label = if squash { + format!("Squash Merge into {head_branch}") + } else { + format!("Merge into {head_branch}") + }; + ContextMenuEntry::new(label).handler(window.handler_for( &git_graph, move |this, _window, cx| { - this.merge_ref(merge_branch.clone(), cx); + this.merge_ref(merge_branch.clone(), squash, cx); }, )) - } - _ => ContextMenuEntry::new("Merge into Current Branch") + } + _ => ContextMenuEntry::new(if squash { + "Squash Merge into Current Branch" + } else { + "Merge into Current Branch" + }) .disabled(true), - }); + }); + } let create_base = branch_name.clone(); menu = menu.entry( "Create Branch from Here…", None, window.handler_for(&git_graph, move |this, window, cx| { - this.create_branch_from(create_base.clone(), window, cx); + this.create_ref_from( + CreateRefKind::Branch, + create_base.clone(), + window, + cx, + ); + }), + ); + + let tag_base = branch_name.clone(); + menu = menu.entry( + "Create Tag…", + None, + window.handler_for(&git_graph, move |this, window, cx| { + this.create_ref_from( + CreateRefKind::Tag, + tag_base.clone(), + window, + cx, + ); }), ); @@ -2927,11 +3028,34 @@ impl GitGraph { } None => { if let Some(tag_name) = ref_name.clone() { + let checkout_tag = tag_name.clone(); + menu = menu.entry( + "Check Out Tag", + None, + window.handler_for(&git_graph, move |this, _window, cx| { + this.checkout_tag(checkout_tag.clone(), cx); + }), + ); + + let create_base = tag_name.clone(); menu = menu.entry( "Create Branch from Here…", None, window.handler_for(&git_graph, move |this, window, cx| { - this.create_branch_from(tag_name.clone(), window, cx); + this.create_ref_from( + CreateRefKind::Branch, + create_base.clone(), + window, + cx, + ); + }), + ); + + menu = menu.entry( + "Delete Tag…", + None, + window.handler_for(&git_graph, move |this, window, cx| { + this.delete_tag(tag_name.clone(), window, cx); }), ); } else { @@ -2948,7 +3072,26 @@ impl GitGraph { "Create Branch from Here…", None, window.handler_for(&git_graph, move |this, window, cx| { - this.create_branch_from(create_base.clone(), window, cx); + this.create_ref_from( + CreateRefKind::Branch, + create_base.clone(), + window, + cx, + ); + }), + ); + + let tag_base = SharedString::from(sha.to_string()); + menu = menu.entry( + "Create Tag…", + None, + window.handler_for(&git_graph, move |this, window, cx| { + this.create_ref_from( + CreateRefKind::Tag, + tag_base.clone(), + window, + cx, + ); }), ); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index c57c113960ebd8..e9aaef3a69c8d4 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -662,6 +662,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_cherry_pick); client.add_entity_request_handler(Self::handle_revert); client.add_entity_request_handler(Self::handle_checkout_commit); + client.add_entity_request_handler(Self::handle_create_tag); + client.add_entity_request_handler(Self::handle_delete_tag); client.add_entity_request_handler(Self::handle_create_remote); client.add_entity_request_handler(Self::handle_remove_remote); client.add_entity_request_handler(Self::handle_delete_branch); @@ -3209,10 +3211,48 @@ impl GitStore { let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; let ref_name = envelope.payload.ref_name; + let squash = envelope.payload.squash; repository_handle .update(&mut cx, |repository_handle, _| { - repository_handle.merge(ref_name) + repository_handle.merge(ref_name, squash) + }) + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_create_tag( + 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 tag_name = envelope.payload.tag_name; + let target = envelope.payload.target; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.create_tag(tag_name, target) + }) + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_delete_tag( + 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 tag_name = envelope.payload.tag_name; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.delete_tag(tag_name) }) .await??; @@ -8099,15 +8139,16 @@ impl Repository { ) } - pub fn merge(&mut self, ref_name: String) -> oneshot::Receiver> { + pub fn merge(&mut self, ref_name: String, squash: bool) -> oneshot::Receiver> { let id = self.id; + let flag = if squash { "--squash" } else { "--no-edit" }; self.send_job( "merge", - Some(format!("git merge --no-edit {ref_name}").into()), + Some(format!("git merge {flag} {ref_name}").into()), move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.merge(ref_name).await + backend.merge(ref_name, squash).await } RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { client @@ -8115,6 +8156,64 @@ impl Repository { project_id: project_id.0, repository_id: id.to_proto(), ref_name, + squash, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + + pub fn create_tag( + &mut self, + tag_name: String, + target: String, + ) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + "create_tag", + Some(format!("git tag {tag_name} {target}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_tag(tag_name, target).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitCreateTag { + project_id: project_id.0, + repository_id: id.to_proto(), + tag_name, + target, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + + pub fn delete_tag(&mut self, tag_name: String) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + "delete_tag", + Some(format!("git tag -d {tag_name}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.delete_tag(tag_name).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitDeleteTag { + project_id: project_id.0, + repository_id: id.to_proto(), + tag_name, }) .await?; diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 1b19123b46a380..02d38162853c5a 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -203,6 +203,20 @@ message GitMerge { uint64 project_id = 1; uint64 repository_id = 2; string ref_name = 3; + bool squash = 4; +} + +message GitCreateTag { + uint64 project_id = 1; + uint64 repository_id = 2; + string tag_name = 3; + string target = 4; +} + +message GitDeleteTag { + uint64 project_id = 1; + uint64 repository_id = 2; + string tag_name = 3; } message GitCherryPick { diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 5c54a20bcbd82d..2a2717bcb0a96f 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -494,7 +494,9 @@ message Envelope { GitMerge git_merge = 462; GitCherryPick git_cherry_pick = 463; GitRevert git_revert = 464; - GitCheckoutCommit git_checkout_commit = 465; // current max + GitCheckoutCommit git_checkout_commit = 465; + GitCreateTag git_create_tag = 466; + GitDeleteTag git_delete_tag = 467; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index e8b4e0fb499959..7b87508eb71517 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -327,6 +327,8 @@ messages!( (GitCherryPick, Background), (GitRevert, Background), (GitCheckoutCommit, Background), + (GitCreateTag, Background), + (GitDeleteTag, Background), (TrustWorktrees, Background), (RestrictWorktrees, Background), (CheckForPushedCommits, Background), @@ -569,6 +571,8 @@ request_messages!( (GitCherryPick, Ack), (GitRevert, Ack), (GitCheckoutCommit, Ack), + (GitCreateTag, Ack), + (GitDeleteTag, Ack), (CheckForPushedCommits, CheckForPushedCommitsResponse), (GitDiff, GitDiffResponse), (GitInit, Ack), @@ -771,6 +775,8 @@ entity_messages!( GitCherryPick, GitRevert, GitCheckoutCommit, + GitCreateTag, + GitDeleteTag, GitCreateRemote, GitRemoveRemote, CheckForPushedCommits, diff --git a/docs/src/git.md b/docs/src/git.md index 98a9523199df39..fbda5b10568ba7 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -110,9 +110,9 @@ To view File History: The Git Graph ({#action git_graph::Open}) visualizes your repository's commit history across branches. Right-clicking a commit or a ref label opens a context menu with common Git operations: -- On a branch label: check out the branch, merge it into the current branch, create a new branch from it, or delete it. -- On a commit: check out the commit, create a branch from it, cherry-pick it, or revert it. -- On a tag label: create a branch from the tag. +- On a branch label: check out the branch, merge or squash-merge it into the current branch, create a new branch or tag from it, or delete it. +- On a commit: check out the commit, create a branch or tag from it, cherry-pick it, or revert it. +- On a tag label: check out the tag, create a branch from it, or delete it. If an operation fails — for example, a merge or cherry-pick with conflicts — Zed shows the error, and any conflicts can be resolved from the Git Panel as usual. You can also add your own entries to this menu with [custom Git commands](./tasks.md#custom-git-commands). From 1fd065a0a7c430020479320d89a17af58dee52d5 Mon Sep 17 00:00:00 2001 From: Sergio Roman Date: Fri, 3 Jul 2026 22:25:17 +0200 Subject: [PATCH 4/6] Support annotated tags in the git graph create tag modal An optional message field creates an annotated tag when filled in and a lightweight tag when left empty. Co-Authored-By: Claude Fable 5 --- crates/fs/src/fake_git_repo.rs | 7 +++- crates/git/src/repository.rs | 57 ++++++++++++++++++++++++++++++--- crates/git_ui/src/git_graph.rs | 45 ++++++++++++++++++++++---- crates/project/src/git_store.rs | 10 ++++-- crates/proto/proto/git.proto | 1 + docs/src/git.md | 2 ++ 6 files changed, 107 insertions(+), 15 deletions(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index d019355712962d..b99657536af218 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -949,7 +949,12 @@ impl GitRepository for FakeGitRepository { }) } - fn create_tag(&self, _name: String, _target: String) -> BoxFuture<'_, Result<()>> { + fn create_tag( + &self, + _name: String, + _target: String, + _message: Option, + ) -> BoxFuture<'_, Result<()>> { async { Ok(()) }.boxed() } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index eb1987e9f72923..7ca8775b57655c 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -880,8 +880,14 @@ pub trait GitRepository: Send + Sync { /// Checks out the given commit-ish with a detached HEAD. fn checkout_commit(&self, commit: String) -> BoxFuture<'_, Result<()>>; - /// Creates a lightweight tag pointing at the given commit-ish. - fn create_tag(&self, name: String, target: String) -> BoxFuture<'_, Result<()>>; + /// Creates a tag pointing at the given commit-ish: annotated when a + /// message is provided, lightweight otherwise. + fn create_tag( + &self, + name: String, + target: String, + message: Option, + ) -> BoxFuture<'_, Result<()>>; /// Deletes the given local tag. fn delete_tag(&self, name: String) -> BoxFuture<'_, Result<()>>; @@ -2269,13 +2275,27 @@ impl GitRepository for RealGitRepository { .boxed() } - fn create_tag(&self, name: String, target: String) -> BoxFuture<'_, Result<()>> { + fn create_tag( + &self, + name: String, + target: String, + message: Option, + ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { let git_binary = git_binary?; - git_binary.run(&["tag", &name, &target]).await?; + match &message { + Some(message) => { + git_binary + .run(&["tag", "-a", &name, "-m", message, &target]) + .await?; + } + None => { + git_binary.run(&["tag", &name, &target]).await?; + } + } anyhow::Ok(()) }) .boxed() @@ -4485,13 +4505,40 @@ mod tests { git_command(repo_dir.path(), ["commit", "-m", "squash feature"]); repository - .create_tag("v1.0".to_string(), "HEAD".to_string()) + .create_tag("v1.0".to_string(), "HEAD".to_string(), None) .await .unwrap(); assert_eq!(git.run(&["tag", "-l", "v1.0"]).await.unwrap(), "v1.0"); + assert_eq!( + git.run(&["cat-file", "-t", "v1.0"]).await.unwrap(), + "commit", + "a tag without a message should be lightweight" + ); + + repository + .create_tag( + "v2.0".to_string(), + "HEAD".to_string(), + Some("release v2.0".to_string()), + ) + .await + .unwrap(); + assert_eq!( + git.run(&["cat-file", "-t", "v2.0"]).await.unwrap(), + "tag", + "a tag with a message should be annotated" + ); + assert_eq!( + git.run(&["tag", "-l", "--format=%(contents:subject)", "v2.0"]) + .await + .unwrap(), + "release v2.0" + ); repository.delete_tag("v1.0".to_string()).await.unwrap(); + repository.delete_tag("v2.0".to_string()).await.unwrap(); assert_eq!(git.run(&["tag", "-l", "v1.0"]).await.unwrap(), ""); + assert_eq!(git.run(&["tag", "-l", "v2.0"]).await.unwrap(), ""); git_command(repo_dir.path(), ["switch", "-c", "side"]); fs::write(repo_dir.path().join("side.txt"), "side\n").unwrap(); diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 5582b9fc911b7f..7bfa500663a505 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1333,6 +1333,9 @@ enum CreateRefKind { struct CreateRefModal { editor: Entity, + /// Only present when creating a tag: a non-empty message creates an + /// annotated tag, an empty one a lightweight tag. + message_editor: Option>, repository: Entity, workspace: WeakEntity, kind: CreateRefKind, @@ -1365,8 +1368,20 @@ impl CreateRefModal { editor.set_placeholder_text(placeholder, window, cx); editor }); + let message_editor = (kind == CreateRefKind::Tag).then(|| { + cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text( + "Message (optional, creates an annotated tag)", + window, + cx, + ); + editor + }) + }); Self { editor, + message_editor, repository, workspace, kind, @@ -1392,12 +1407,19 @@ impl CreateRefModal { }), format!("switch -c {name} {base}"), ), - CreateRefKind::Tag => ( - self.repository.update(cx, |repository, _| { - repository.create_tag(name.clone(), base.to_string()) - }), - format!("tag {name} {base}"), - ), + CreateRefKind::Tag => { + let message = self.message_editor.as_ref().and_then(|editor| { + let text = editor.read(cx).text(cx).trim().to_string(); + (!text.is_empty()).then_some(text) + }); + let flag = if message.is_some() { "-a " } else { "" }; + ( + self.repository.update(cx, |repository, _| { + repository.create_tag(name.clone(), base.to_string(), message) + }), + format!("tag {flag}{name} {base}"), + ) + } }; cx.spawn(async move |_, cx| { if let Ok(Err(error)) = receiver.await @@ -1444,6 +1466,17 @@ impl Render for CreateRefModal { .border_color(cx.theme().colors().border_variant) .child(self.editor.clone()), ) + .when_some(self.message_editor.clone(), |this, message_editor| { + this.child( + div() + .py_2() + .px_3() + .bg(cx.theme().colors().editor_background) + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(message_editor), + ) + }) } } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index e9aaef3a69c8d4..b1ee9df9a15577 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -3231,10 +3231,11 @@ impl GitStore { let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; let tag_name = envelope.payload.tag_name; let target = envelope.payload.target; + let message = envelope.payload.message; repository_handle .update(&mut cx, |repository_handle, _| { - repository_handle.create_tag(tag_name, target) + repository_handle.create_tag(tag_name, target, message) }) .await??; @@ -8171,15 +8172,17 @@ impl Repository { &mut self, tag_name: String, target: String, + message: Option, ) -> oneshot::Receiver> { let id = self.id; + let flag = if message.is_some() { "-a " } else { "" }; self.send_job( "create_tag", - Some(format!("git tag {tag_name} {target}").into()), + Some(format!("git tag {flag}{tag_name} {target}").into()), move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.create_tag(tag_name, target).await + backend.create_tag(tag_name, target, message).await } RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { client @@ -8188,6 +8191,7 @@ impl Repository { repository_id: id.to_proto(), tag_name, target, + message, }) .await?; diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 02d38162853c5a..12c443e4bf2181 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -211,6 +211,7 @@ message GitCreateTag { uint64 repository_id = 2; string tag_name = 3; string target = 4; + optional string message = 5; } message GitDeleteTag { diff --git a/docs/src/git.md b/docs/src/git.md index fbda5b10568ba7..b8e4b804fd1719 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -114,6 +114,8 @@ The Git Graph ({#action git_graph::Open}) visualizes your repository's commit hi - On a commit: check out the commit, create a branch or tag from it, cherry-pick it, or revert it. - On a tag label: check out the tag, create a branch from it, or delete it. +When creating a tag, the message field is optional: providing one creates an annotated tag, leaving it empty creates a lightweight tag. + If an operation fails — for example, a merge or cherry-pick with conflicts — Zed shows the error, and any conflicts can be resolved from the Git Panel as usual. You can also add your own entries to this menu with [custom Git commands](./tasks.md#custom-git-commands). ## Fetch, Push, and Pull From 979f804689e4c3d3e6a163d6fbe0f2caaefe23f4 Mon Sep 17 00:00:00 2001 From: Sergio Roman Date: Fri, 3 Jul 2026 22:40:16 +0200 Subject: [PATCH 5/6] Refresh the git graph immediately after its context menu operations Ref changes like tag creation or deletion emit no repository event, so the graph would otherwise wait indefinitely (or on watcher latency) to reflect an operation it initiated itself. Co-Authored-By: Claude Fable 5 --- crates/git_ui/src/git_graph.rs | 98 +++++++++++++++++++++++----------- 1 file changed, 68 insertions(+), 30 deletions(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 7bfa500663a505..64058c896c4add 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1338,6 +1338,7 @@ struct CreateRefModal { message_editor: Option>, repository: Entity, workspace: WeakEntity, + git_graph: WeakEntity, kind: CreateRefKind, base: SharedString, } @@ -1356,6 +1357,7 @@ impl CreateRefModal { kind: CreateRefKind, base: SharedString, workspace: WeakEntity, + git_graph: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -1384,6 +1386,7 @@ impl CreateRefModal { message_editor, repository, workspace, + git_graph, kind, base, } @@ -1421,12 +1424,19 @@ impl CreateRefModal { ) } }; - cx.spawn(async move |_, cx| { - if let Ok(Err(error)) = receiver.await - && let Some(workspace) = workspace.upgrade() - { - cx.update(|cx| show_error_toast(workspace, command_label, error, cx)); + let git_graph = self.git_graph.clone(); + cx.spawn(async move |_, cx| match receiver.await { + Ok(Ok(())) => { + git_graph + .update(cx, |git_graph, cx| git_graph.invalidate_state(cx)) + .ok(); } + Ok(Err(error)) => { + if let Some(workspace) = workspace.upgrade() { + cx.update(|cx| show_error_toast(workspace, command_label, error, cx)); + } + } + Err(_) => {} }) .detach(); cx.emit(DismissEvent); @@ -2628,11 +2638,20 @@ impl GitGraph { cx: &mut Context, ) { let workspace = self.workspace.clone(); - cx.spawn(async move |_, cx| { - if let Ok(Err(error)) = receiver.await - && let Some(workspace) = workspace.upgrade() - { - cx.update(|cx| show_error_toast(workspace, command_label, error, cx)); + cx.spawn(async move |this, cx| { + match receiver.await { + Ok(Ok(())) => { + // Refresh the graph right away rather than waiting for the + // file watcher to notice the change; ref updates like tag + // edits produce no repository event at all. + this.update(cx, |this, cx| this.invalidate_state(cx)).ok(); + } + Ok(Err(error)) => { + if let Some(workspace) = workspace.upgrade() { + cx.update(|cx| show_error_toast(workspace, command_label, error, cx)); + } + } + Err(_) => {} } }) .detach(); @@ -2674,7 +2693,7 @@ impl GitGraph { return; }; let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |_, cx| { + cx.spawn_in(window, async move |this, cx| { let answer = cx.update(|window, cx| { window.prompt( PromptLevel::Warning, @@ -2693,12 +2712,17 @@ impl GitGraph { repository.delete_tag(tag_name.to_string()) }) .await?; - if let Err(error) = result - && let Some(workspace) = workspace.upgrade() - { - cx.update(|_window, cx| { - show_error_toast(workspace, format!("tag -d {tag_name}"), error, cx) - })?; + match result { + Ok(()) => { + this.update(cx, |this, cx| this.invalidate_state(cx)).ok(); + } + Err(error) => { + if let Some(workspace) = workspace.upgrade() { + cx.update(|_window, cx| { + show_error_toast(workspace, format!("tag -d {tag_name}"), error, cx) + })?; + } + } } anyhow::Ok(()) @@ -2761,10 +2785,19 @@ impl GitGraph { let Some(workspace) = self.workspace.upgrade() else { return; }; + let git_graph = cx.weak_entity(); workspace.update(cx, |workspace, cx| { let workspace_handle = cx.weak_entity(); workspace.toggle_modal(window, cx, |window, cx| { - CreateRefModal::new(repository, kind, base, workspace_handle, window, cx) + CreateRefModal::new( + repository, + kind, + base, + workspace_handle, + git_graph, + window, + cx, + ) }); }); } @@ -2779,7 +2812,7 @@ impl GitGraph { return; }; let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |_, cx| { + cx.spawn_in(window, async move |this, cx| { let answer = cx.update(|window, cx| { window.prompt( PromptLevel::Warning, @@ -2848,17 +2881,22 @@ impl GitGraph { } }; - if let Err(error) = result - && let Some(workspace) = workspace.upgrade() - { - cx.update(|_window, cx| { - show_error_toast( - workspace, - delete_branch_command(is_remote, &branch_name, attempted_force), - error, - cx, - ) - })?; + match result { + Ok(()) => { + this.update(cx, |this, cx| this.invalidate_state(cx)).ok(); + } + Err(error) => { + if let Some(workspace) = workspace.upgrade() { + cx.update(|_window, cx| { + show_error_toast( + workspace, + delete_branch_command(is_remote, &branch_name, attempted_force), + error, + cx, + ) + })?; + } + } } anyhow::Ok(()) From 3a939a9e58a5f05e3b36c5a4d48dac6803121aef Mon Sep 17 00:00:00 2001 From: Sergio Roman Date: Fri, 3 Jul 2026 22:45:55 +0200 Subject: [PATCH 6/6] Force git graph reload after menu operations Refreshing the view alone re-read the repository's cached graph data, so ref changes that emit no repository event (such as tag edits) still showed stale entries. Drop the repository's graph cache before rebuilding. Co-Authored-By: Claude Fable 5 --- crates/git_ui/src/git_graph.rs | 19 +++++++++++++++---- crates/project/src/git_store.rs | 7 +++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 64058c896c4add..7f3056804d97dd 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1428,7 +1428,7 @@ impl CreateRefModal { cx.spawn(async move |_, cx| match receiver.await { Ok(Ok(())) => { git_graph - .update(cx, |git_graph, cx| git_graph.invalidate_state(cx)) + .update(cx, |git_graph, cx| git_graph.reload_graph(cx)) .ok(); } Ok(Err(error)) => { @@ -1526,6 +1526,17 @@ impl GitGraph { cx.notify(); } + /// Drops the repository's cached graph data and rebuilds the view. Unlike + /// `invalidate_state` alone, this forces `git log` to re-run, which is + /// required after operations (such as tag edits) that change refs without + /// emitting a repository event. + fn reload_graph(&mut self, cx: &mut Context) { + if let Some(repository) = self.get_repository(cx) { + repository.update(cx, |repository, _| repository.invalidate_graph_data()); + } + self.invalidate_state(cx); + } + /// Computes the height of a single commit row in the git graph. /// /// The returned value is snapped to the nearest physical pixel. This is @@ -2644,7 +2655,7 @@ impl GitGraph { // Refresh the graph right away rather than waiting for the // file watcher to notice the change; ref updates like tag // edits produce no repository event at all. - this.update(cx, |this, cx| this.invalidate_state(cx)).ok(); + this.update(cx, |this, cx| this.reload_graph(cx)).ok(); } Ok(Err(error)) => { if let Some(workspace) = workspace.upgrade() { @@ -2714,7 +2725,7 @@ impl GitGraph { .await?; match result { Ok(()) => { - this.update(cx, |this, cx| this.invalidate_state(cx)).ok(); + this.update(cx, |this, cx| this.reload_graph(cx)).ok(); } Err(error) => { if let Some(workspace) = workspace.upgrade() { @@ -2883,7 +2894,7 @@ impl GitGraph { match result { Ok(()) => { - this.update(cx, |this, cx| this.invalidate_state(cx)).ok(); + this.update(cx, |this, cx| this.reload_graph(cx)).ok(); } Err(error) => { if let Some(workspace) = workspace.upgrade() { diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index b1ee9df9a15577..83cff3c8aa4d38 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -5866,6 +5866,13 @@ impl Repository { .detach(); } + /// Drops the cached graph data so the next `graph_data` call re-runs + /// `git log`. Used to reflect ref changes (such as tag edits) that produce + /// no repository event of their own. + pub fn invalidate_graph_data(&mut self) { + self.initial_graph_data.clear(); + } + pub fn graph_data( &mut self, log_source: LogSource,