diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 431350971ce590..893ad5f4674426 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -491,6 +491,12 @@ 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_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..b99657536af218 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -930,6 +930,38 @@ impl GitRepository for FakeGitRepository { }) } + fn merge(&self, _ref_name: String, _squash: bool) -> 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 create_tag( + &self, + _name: String, + _target: String, + _message: Option, + ) -> 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 f09c13f54ab26c..7ca8775b57655c 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -867,6 +867,31 @@ pub trait GitRepository: Send + Sync { force: bool, ) -> 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<()>>; + + /// 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<()>>; + + /// 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<()>>; + fn worktrees(&self) -> BoxFuture<'_, Result>>; /// Returns the creation time of a linked worktree's git metadata @@ -2201,6 +2226,93 @@ impl GitRepository for RealGitRepository { .boxed() } + 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?; + let flag = if squash { "--squash" } else { "--no-edit" }; + git_binary.run(&["merge", flag, &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 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?; + 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() + } + + 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, @@ -4334,6 +4446,133 @@ 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(), 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(), 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(); + 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/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..7f3056804d97dd 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1,11 +1,14 @@ 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}; use editor::Editor; use file_icons::FileIcons; +use futures::channel::oneshot; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, commit::ParsedCommitMessage, @@ -19,7 +22,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, @@ -1322,6 +1325,171 @@ struct GitGraphContextMenu { _subscription: Subscription, } +#[derive(Clone, Copy, PartialEq)] +enum CreateRefKind { + Branch, + Tag, +} + +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, + git_graph: WeakEntity, + kind: CreateRefKind, + base: SharedString, +} + +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 CreateRefModal { + fn new( + repository: Entity, + kind: CreateRefKind, + base: SharedString, + workspace: WeakEntity, + git_graph: 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(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, + git_graph, + kind, + 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 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, 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 => { + 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}"), + ) + } + }; + 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.reload_graph(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); + } +} + +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("GitGraphCreateRefModal") + .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(icon).size(IconSize::XSmall)) + .child(Label::new(title).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()), + ) + .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), + ) + }) + } +} + pub struct GitGraph { focus_handle: FocusHandle, search_state: SearchState, @@ -1358,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 @@ -2461,6 +2640,281 @@ impl GitGraph { .ok(); } + /// 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 workspace = self.workspace.clone(); + 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.reload_graph(cx)).ok(); + } + Ok(Err(error)) => { + if let Some(workspace) = workspace.upgrade() { + cx.update(|cx| show_error_toast(workspace, command_label, error, cx)); + } + } + Err(_) => {} + } + }) + .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, 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.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 |this, 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?; + match result { + Ok(()) => { + this.update(cx, |this, cx| this.reload_graph(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(()) + }) + .detach(); + } + + fn cherry_pick_commit(&mut self, commit_sha: Oid, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + 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; + }; + 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_commit(&mut self, commit_sha: Oid, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let receiver = repository.update(cx, |repository, _| { + repository.checkout_commit(commit_sha.to_string()) + }); + self.detach_op_with_error_toast( + format!("checkout --detach {}", commit_sha.display_short()), + receiver, + cx, + ); + } + + fn create_ref_from( + &mut self, + kind: CreateRefKind, + 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; + }; + 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, + git_graph, + 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 |this, 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) + } + } + }; + + match result { + Ok(()) => { + this.update(cx, |this, cx| this.reload_graph(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(()) + }) + .detach(); + } + fn deploy_entry_context_menu( &mut self, position: Point, @@ -2479,6 +2933,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 +3027,176 @@ 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); + }, + )), + ); + + 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(), squash, cx); + }, + )) + } + _ => 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_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, + ); + }), + ); + + 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() { + 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_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 { + menu = menu.entry( + "Check Out Commit", + None, + window.handler_for(&git_graph, move |this, _window, cx| { + this.checkout_commit(sha, 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_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, + ); + }), + ); + + menu = menu.entry( + "Cherry-Pick Commit", + None, + window.handler_for(&git_graph, move |this, _window, cx| { + this.cherry_pick_commit(sha, cx); + }), + ); + + menu = menu.entry( + "Revert Commit", + None, + window.handler_for(&git_graph, move |this, _window, cx| { + this.revert_commit(sha, cx); + }), + ); + } + } + } + menu + }) .map(|mut menu| { menu = menu.separator().header("Custom Commands"); @@ -7012,6 +7653,84 @@ mod tests { ); } + #[gpui::test] + async fn test_checkout_commit_detaches_head(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + 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 (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(); + + repository.read_with(&*cx, |repository, _| { + assert!( + repository.snapshot().branch.is_some(), + "a branch should be checked out initially" + ); + }); + + git_graph.update_in(cx, |git_graph, _window, cx| { + git_graph.checkout_commit(commit_sha, cx); + }); + cx.run_until_parked(); + + repository.read_with(&*cx, |repository, _| { + assert!( + repository.snapshot().branch.is_none(), + "HEAD should be detached after checking out a commit" + ); + }); + } + #[test] fn test_ref_name_from_decoration() { assert_eq!( diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 778743163d6516..83cff3c8aa4d38 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -658,6 +658,12 @@ 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_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); @@ -3197,6 +3203,117 @@ 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; + let squash = envelope.payload.squash; + + repository_handle + .update(&mut cx, |repository_handle, _| { + 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; + let message = envelope.payload.message; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.create_tag(tag_name, target, message) + }) + .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??; + + 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, @@ -5749,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, @@ -8023,6 +8147,172 @@ impl Repository { ) } + 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 {flag} {ref_name}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.merge(ref_name, squash).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitMerge { + 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, + 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 {flag}{tag_name} {target}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_tag(tag_name, target, message).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitCreateTag { + project_id: project_id.0, + repository_id: id.to_proto(), + tag_name, + target, + message, + }) + .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?; + + 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..12c443e4bf2181 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -199,6 +199,45 @@ message GitRenameBranch { string new_name = 4; } +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; + optional string message = 5; +} + +message GitDeleteTag { + uint64 project_id = 1; + uint64 repository_id = 2; + string tag_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..2a2717bcb0a96f 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -489,7 +489,14 @@ 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; + 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 3fbd23527579e2..7b87508eb71517 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -323,6 +323,12 @@ messages!( (GitCreateBranch, Background), (GitChangeBranch, Background), (GitRenameBranch, Background), + (GitMerge, Background), + (GitCherryPick, Background), + (GitRevert, Background), + (GitCheckoutCommit, Background), + (GitCreateTag, Background), + (GitDeleteTag, Background), (TrustWorktrees, Background), (RestrictWorktrees, Background), (CheckForPushedCommits, Background), @@ -561,6 +567,12 @@ request_messages!( (GitCreateBranch, Ack), (GitChangeBranch, Ack), (GitRenameBranch, Ack), + (GitMerge, Ack), + (GitCherryPick, Ack), + (GitRevert, Ack), + (GitCheckoutCommit, Ack), + (GitCreateTag, Ack), + (GitDeleteTag, Ack), (CheckForPushedCommits, CheckForPushedCommitsResponse), (GitDiff, GitDiffResponse), (GitInit, Ack), @@ -759,6 +771,12 @@ entity_messages!( GitChangeBranch, GitRenameBranch, GitCreateBranch, + GitMerge, + GitCherryPick, + GitRevert, + GitCheckoutCommit, + GitCreateTag, + GitDeleteTag, GitCreateRemote, GitRemoveRemote, CheckForPushedCommits, diff --git a/docs/src/git.md b/docs/src/git.md index 68514d44092d0c..b8e4b804fd1719 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -106,6 +106,18 @@ 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 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. + +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 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}.