git: Amend (#28187)

Smit Barmase and Cole Miller created

Adds git amend support.

- [x] Turn existing commit button into split button
- [x] Clean up + Handle shortcuts/focus cases
- [x] Test remote

Release Notes:

- Added git amend support.

---------

Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

assets/keymaps/default-linux.json               |  11 
assets/keymaps/default-macos.json               |  11 
crates/fs/src/fake_git_repo.rs                  |   5 
crates/git/src/git.rs                           |   2 
crates/git/src/repository.rs                    |  17 
crates/git_ui/src/commit_modal.rs               | 278 ++++++++++++++--
crates/git_ui/src/git_panel.rs                  | 321 ++++++++++++++++--
crates/git_ui/src/git_ui.rs                     |   1 
crates/project/src/git_store.rs                 |  22 +
crates/proto/proto/git.proto                    |   5 
crates/ui/src/components/button/split_button.rs |   6 
11 files changed, 595 insertions(+), 84 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -782,6 +782,7 @@
       "shift-tab": "git_panel::FocusEditor",
       "escape": "git_panel::ToggleFocus",
       "ctrl-enter": "git::Commit",
+      "ctrl-shift-enter": "git::Amend",
       "alt-enter": "menu::SecondaryConfirm",
       "delete": ["git::RestoreFile", { "skip_prompt": false }],
       "backspace": ["git::RestoreFile", { "skip_prompt": false }],
@@ -790,12 +791,20 @@
       "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }]
     }
   },
+  {
+    "context": "GitPanel && CommitEditor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "escape": "git::Cancel"
+    }
+  },
   {
     "context": "GitCommit > Editor",
     "bindings": {
       "escape": "menu::Cancel",
       "enter": "editor::Newline",
       "ctrl-enter": "git::Commit",
+      "ctrl-shift-enter": "git::Amend",
       "alt-l": "git::GenerateCommitMessage"
     }
   },
@@ -817,6 +826,7 @@
     "context": "GitDiff > Editor",
     "bindings": {
       "ctrl-enter": "git::Commit",
+      "ctrl-shift-enter": "git::Amend",
       "ctrl-space": "git::StageAll",
       "ctrl-shift-space": "git::UnstageAll"
     }
@@ -835,6 +845,7 @@
       "shift-tab": "git_panel::FocusChanges",
       "enter": "editor::Newline",
       "ctrl-enter": "git::Commit",
+      "ctrl-shift-enter": "git::Amend",
       "alt-up": "git_panel::FocusChanges",
       "alt-l": "git::GenerateCommitMessage"
     }

assets/keymaps/default-macos.json 🔗

@@ -855,17 +855,26 @@
       "shift-tab": "git_panel::FocusEditor",
       "escape": "git_panel::ToggleFocus",
       "cmd-enter": "git::Commit",
+      "cmd-shift-enter": "git::Amend",
       "backspace": ["git::RestoreFile", { "skip_prompt": false }],
       "delete": ["git::RestoreFile", { "skip_prompt": false }],
       "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }],
       "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }]
     }
   },
+  {
+    "context": "GitPanel && CommitEditor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "escape": "git::Cancel"
+    }
+  },
   {
     "context": "GitDiff > Editor",
     "use_key_equivalents": true,
     "bindings": {
       "cmd-enter": "git::Commit",
+      "cmd-shift-enter": "git::Amend",
       "cmd-ctrl-y": "git::StageAll",
       "cmd-ctrl-shift-y": "git::UnstageAll"
     }
@@ -876,6 +885,7 @@
     "bindings": {
       "enter": "editor::Newline",
       "cmd-enter": "git::Commit",
+      "cmd-shift-enter": "git::Amend",
       "tab": "git_panel::FocusChanges",
       "shift-tab": "git_panel::FocusChanges",
       "alt-up": "git_panel::FocusChanges",
@@ -905,6 +915,7 @@
       "enter": "editor::Newline",
       "escape": "menu::Cancel",
       "cmd-enter": "git::Commit",
+      "cmd-shift-enter": "git::Amend",
       "alt-tab": "git::GenerateCommitMessage"
     }
   },

crates/fs/src/fake_git_repo.rs 🔗

@@ -5,8 +5,8 @@ use futures::future::{self, BoxFuture};
 use git::{
     blame::Blame,
     repository::{
-        AskPassDelegate, Branch, CommitDetails, GitRepository, GitRepositoryCheckpoint,
-        PushOptions, Remote, RepoPath, ResetMode,
+        AskPassDelegate, Branch, CommitDetails, CommitOptions, GitRepository,
+        GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode,
     },
     status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
 };
@@ -365,6 +365,7 @@ impl GitRepository for FakeGitRepository {
         &self,
         _message: gpui::SharedString,
         _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
+        _options: CommitOptions,
         _env: Arc<HashMap<String, String>>,
     ) -> BoxFuture<Result<()>> {
         unimplemented!()

crates/git/src/git.rs 🔗

@@ -50,6 +50,8 @@ actions!(
         Pull,
         Fetch,
         Commit,
+        Amend,
+        Cancel,
         ExpandCommitEditor,
         GenerateCommitMessage,
         Init,

crates/git/src/repository.rs 🔗

@@ -74,6 +74,11 @@ impl Upstream {
     }
 }
 
+#[derive(Clone, Copy, Default)]
+pub struct CommitOptions {
+    pub amend: bool,
+}
+
 #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 pub enum UpstreamTracking {
     /// Remote ref not present in local repository.
@@ -252,6 +257,7 @@ pub trait GitRepository: Send + Sync {
         &self,
         message: SharedString,
         name_and_email: Option<(SharedString, SharedString)>,
+        options: CommitOptions,
         env: Arc<HashMap<String, String>>,
     ) -> BoxFuture<Result<()>>;
 
@@ -368,8 +374,8 @@ impl RealGitRepository {
 
 #[derive(Clone, Debug)]
 pub struct GitRepositoryCheckpoint {
-    ref_name: String,
-    commit_sha: Oid,
+    pub ref_name: String,
+    pub commit_sha: Oid,
 }
 
 impl GitRepository for RealGitRepository {
@@ -957,6 +963,7 @@ impl GitRepository for RealGitRepository {
         &self,
         message: SharedString,
         name_and_email: Option<(SharedString, SharedString)>,
+        options: CommitOptions,
         env: Arc<HashMap<String, String>>,
     ) -> BoxFuture<Result<()>> {
         let working_directory = self.working_directory();
@@ -969,6 +976,10 @@ impl GitRepository for RealGitRepository {
                     .arg(&message.to_string())
                     .arg("--cleanup=strip");
 
+                if options.amend {
+                    cmd.arg("--amend");
+                }
+
                 if let Some((name, email)) = name_and_email {
                     cmd.arg("--author").arg(&format!("{name} <{email}>"));
                 }
@@ -1765,6 +1776,7 @@ mod tests {
         repo.commit(
             "Initial commit".into(),
             None,
+            CommitOptions::default(),
             Arc::new(checkpoint_author_envs()),
         )
         .await
@@ -1793,6 +1805,7 @@ mod tests {
         repo.commit(
             "Commit after checkpoint".into(),
             None,
+            CommitOptions::default(),
             Arc::new(checkpoint_author_envs()),
         )
         .await

crates/git_ui/src/commit_modal.rs 🔗

@@ -1,8 +1,12 @@
 use crate::branch_picker::{self, BranchList};
 use crate::git_panel::{GitPanel, commit_message_editor};
-use git::{Commit, GenerateCommitMessage};
+use git::repository::CommitOptions;
+use git::{Amend, Commit, GenerateCommitMessage};
+use language::Buffer;
 use panel::{panel_button, panel_editor_style, panel_filled_button};
-use ui::{KeybindingHint, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
+use ui::{
+    ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*,
+};
 
 use editor::{Editor, EditorElement};
 use gpui::*;
@@ -100,6 +104,9 @@ impl CommitModal {
         workspace.register_action(|workspace, _: &Commit, window, cx| {
             CommitModal::toggle(workspace, window, cx);
         });
+        workspace.register_action(|workspace, _: &Amend, window, cx| {
+            CommitModal::toggle(workspace, window, cx);
+        });
     }
 
     pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
@@ -214,23 +221,67 @@ impl CommitModal {
             )
     }
 
+    fn render_git_commit_menu(
+        &self,
+        id: impl Into<ElementId>,
+        keybinding_target: Option<FocusHandle>,
+    ) -> impl IntoElement {
+        PopoverMenu::new(id.into())
+            .trigger(
+                ui::ButtonLike::new_rounded_right("commit-split-button-right")
+                    .layer(ui::ElevationIndex::ModalSurface)
+                    .size(ui::ButtonSize::None)
+                    .child(
+                        div()
+                            .px_1()
+                            .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
+                    ),
+            )
+            .menu(move |window, cx| {
+                Some(ContextMenu::build(window, cx, |context_menu, _, _| {
+                    context_menu
+                        .when_some(keybinding_target.clone(), |el, keybinding_target| {
+                            el.context(keybinding_target.clone())
+                        })
+                        .action("Amend...", Amend.boxed_clone())
+                }))
+            })
+            .anchor(Corner::TopRight)
+    }
+
     pub fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let (can_commit, tooltip, commit_label, co_authors, generate_commit_message, active_repo) =
-            self.git_panel.update(cx, |git_panel, cx| {
-                let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
-                let title = git_panel.commit_button_title();
-                let co_authors = git_panel.render_co_authors(cx);
-                let generate_commit_message = git_panel.render_generate_commit_message_button(cx);
-                let active_repo = git_panel.active_repository.clone();
-                (
-                    can_commit,
-                    tooltip,
-                    title,
-                    co_authors,
-                    generate_commit_message,
-                    active_repo,
-                )
-            });
+        let (
+            can_commit,
+            tooltip,
+            commit_label,
+            co_authors,
+            generate_commit_message,
+            active_repo,
+            is_amend_pending,
+            has_previous_commit,
+        ) = self.git_panel.update(cx, |git_panel, cx| {
+            let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
+            let title = git_panel.commit_button_title();
+            let co_authors = git_panel.render_co_authors(cx);
+            let generate_commit_message = git_panel.render_generate_commit_message_button(cx);
+            let active_repo = git_panel.active_repository.clone();
+            let is_amend_pending = git_panel.amend_pending();
+            let has_previous_commit = active_repo
+                .as_ref()
+                .and_then(|repo| repo.read(cx).branch.as_ref())
+                .and_then(|branch| branch.most_recent_commit.as_ref())
+                .is_some();
+            (
+                can_commit,
+                tooltip,
+                title,
+                co_authors,
+                generate_commit_message,
+                active_repo,
+                is_amend_pending,
+                has_previous_commit,
+            )
+        });
 
         let branch = active_repo
             .as_ref()
@@ -277,21 +328,6 @@ impl CommitModal {
                 None
             };
 
-        let commit_button = panel_filled_button(commit_label)
-            .tooltip({
-                let panel_editor_focus_handle = focus_handle.clone();
-                move |window, cx| {
-                    Tooltip::for_action_in(tooltip, &Commit, &panel_editor_focus_handle, window, cx)
-                }
-            })
-            .disabled(!can_commit)
-            .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
-                telemetry::event!("Git Committed", source = "Git Modal");
-                this.git_panel
-                    .update(cx, |git_panel, cx| git_panel.commit_changes(window, cx));
-                cx.emit(DismissEvent);
-            }));
-
         h_flex()
             .group("commit_editor_footer")
             .flex_none()
@@ -324,21 +360,188 @@ impl CommitModal {
                     .px_1()
                     .gap_4()
                     .children(close_kb_hint)
-                    .child(commit_button),
+                    .when(is_amend_pending, |this| {
+                        let focus_handle = focus_handle.clone();
+                        this.child(
+                            panel_filled_button(commit_label)
+                                .tooltip(move |window, cx| {
+                                    if can_commit {
+                                        Tooltip::for_action_in(
+                                            tooltip,
+                                            &Amend,
+                                            &focus_handle,
+                                            window,
+                                            cx,
+                                        )
+                                    } else {
+                                        Tooltip::simple(tooltip, cx)
+                                    }
+                                })
+                                .disabled(!can_commit)
+                                .on_click(move |_, window, cx| {
+                                    window.dispatch_action(Box::new(git::Commit), cx);
+                                }),
+                        )
+                    })
+                    .when(!is_amend_pending, |this| {
+                        this.when(has_previous_commit, |this| {
+                            this.child(SplitButton::new(
+                                ui::ButtonLike::new_rounded_left(ElementId::Name(
+                                    format!("split-button-left-{}", commit_label).into(),
+                                ))
+                                .layer(ui::ElevationIndex::ModalSurface)
+                                .size(ui::ButtonSize::Compact)
+                                .child(
+                                    div()
+                                        .child(Label::new(commit_label).size(LabelSize::Small))
+                                        .mr_0p5(),
+                                )
+                                .on_click(move |_, window, cx| {
+                                    window.dispatch_action(Box::new(git::Commit), cx);
+                                })
+                                .disabled(!can_commit)
+                                .tooltip({
+                                    let focus_handle = focus_handle.clone();
+                                    move |window, cx| {
+                                        if can_commit {
+                                            Tooltip::with_meta_in(
+                                                tooltip,
+                                                Some(&git::Commit),
+                                                "git commit",
+                                                &focus_handle.clone(),
+                                                window,
+                                                cx,
+                                            )
+                                        } else {
+                                            Tooltip::simple(tooltip, cx)
+                                        }
+                                    }
+                                }),
+                                self.render_git_commit_menu(
+                                    ElementId::Name(
+                                        format!("split-button-right-{}", commit_label).into(),
+                                    ),
+                                    Some(focus_handle.clone()),
+                                )
+                                .into_any_element(),
+                            ))
+                        })
+                        .when(!has_previous_commit, |this| {
+                            this.child(
+                                panel_filled_button(commit_label)
+                                    .tooltip(move |window, cx| {
+                                        if can_commit {
+                                            Tooltip::with_meta_in(
+                                                tooltip,
+                                                Some(&git::Commit),
+                                                "git commit",
+                                                &focus_handle,
+                                                window,
+                                                cx,
+                                            )
+                                        } else {
+                                            Tooltip::simple(tooltip, cx)
+                                        }
+                                    })
+                                    .disabled(!can_commit)
+                                    .on_click(move |_, window, cx| {
+                                        window.dispatch_action(Box::new(git::Commit), cx);
+                                    }),
+                            )
+                        })
+                    }),
             )
     }
 
     fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
-        cx.emit(DismissEvent);
+        if self.git_panel.read(cx).amend_pending() {
+            self.git_panel
+                .update(cx, |git_panel, _| git_panel.set_amend_pending(false));
+            cx.notify();
+        } else {
+            cx.emit(DismissEvent);
+        }
+    }
+
+    pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
+        self.commit_editor
+            .read(cx)
+            .buffer()
+            .read(cx)
+            .as_singleton()
+            .unwrap()
+            .clone()
     }
 
     fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
+        if self.git_panel.read(cx).amend_pending() {
+            return;
+        }
         telemetry::event!("Git Committed", source = "Git Modal");
-        self.git_panel
-            .update(cx, |git_panel, cx| git_panel.commit_changes(window, cx));
+        self.git_panel.update(cx, |git_panel, cx| {
+            git_panel.commit_changes(CommitOptions { amend: false }, window, cx)
+        });
         cx.emit(DismissEvent);
     }
 
+    fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(active_repository) = self.git_panel.read(cx).active_repository.as_ref() else {
+            return;
+        };
+        let Some(branch) = active_repository.read(cx).branch.as_ref() else {
+            return;
+        };
+        let Some(recent_sha) = branch
+            .most_recent_commit
+            .as_ref()
+            .map(|commit| commit.sha.to_string())
+        else {
+            return;
+        };
+        if self
+            .commit_editor
+            .focus_handle(cx)
+            .contains_focused(window, cx)
+        {
+            if !self.git_panel.read(cx).amend_pending() {
+                self.git_panel.update(cx, |git_panel, _| {
+                    git_panel.set_amend_pending(true);
+                });
+                cx.notify();
+                if self.commit_editor.read(cx).is_empty(cx) {
+                    let detail_task = self.git_panel.update(cx, |git_panel, cx| {
+                        git_panel.load_commit_details(recent_sha, cx)
+                    });
+                    cx.spawn(async move |this, cx| {
+                        if let Ok(message) = detail_task.await.map(|detail| detail.message) {
+                            this.update(cx, |this, cx| {
+                                this.commit_message_buffer(cx).update(cx, |buffer, cx| {
+                                    let insert_position = buffer.anchor_before(buffer.len());
+                                    buffer.edit(
+                                        [(insert_position..insert_position, message)],
+                                        None,
+                                        cx,
+                                    );
+                                });
+                            })
+                            .log_err();
+                        }
+                    })
+                    .detach();
+                }
+            } else {
+                telemetry::event!("Git Amended", source = "Git Panel");
+                self.git_panel.update(cx, |git_panel, cx| {
+                    git_panel.set_amend_pending(false);
+                    git_panel.commit_changes(CommitOptions { amend: true }, window, cx);
+                });
+                cx.emit(DismissEvent);
+            }
+        } else {
+            cx.propagate();
+        }
+    }
+
     fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         if self.branch_list_handle.is_focused(window, cx) {
             self.focus_handle(cx).focus(window)
@@ -361,6 +564,7 @@ impl Render for CommitModal {
             .key_context("GitCommit")
             .on_action(cx.listener(Self::dismiss))
             .on_action(cx.listener(Self::commit))
+            .on_action(cx.listener(Self::amend))
             .on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| {
                 this.git_panel.update(cx, |panel, cx| {
                     panel.generate_commit_message(cx);

crates/git_ui/src/git_panel.rs 🔗

@@ -21,11 +21,11 @@ use editor::{
 use futures::StreamExt as _;
 use git::blame::ParsedCommitMessage;
 use git::repository::{
-    Branch, CommitDetails, CommitSummary, DiffType, PushOptions, Remote, RemoteCommandOutput,
-    ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus,
+    Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, PushOptions, Remote,
+    RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus,
 };
 use git::status::StageStatus;
-use git::{Commit, ToggleStaged, repository::RepoPath, status::FileStatus};
+use git::{Amend, ToggleStaged, repository::RepoPath, status::FileStatus};
 use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
 use gpui::{
     Action, Animation, AnimationExt as _, Axis, ClickEvent, Corner, DismissEvent, Entity,
@@ -59,8 +59,8 @@ use std::{collections::HashSet, sync::Arc, time::Duration, usize};
 use strum::{IntoEnumIterator, VariantNames};
 use time::OffsetDateTime;
 use ui::{
-    Checkbox, ContextMenu, ElevationIndex, PopoverMenu, Scrollbar, ScrollbarState, Tooltip,
-    prelude::*,
+    Checkbox, ContextMenu, ElevationIndex, PopoverMenu, Scrollbar, ScrollbarState, SplitButton,
+    Tooltip, prelude::*,
 };
 use util::{ResultExt, TryFutureExt, maybe};
 use workspace::AppState;
@@ -340,6 +340,7 @@ pub struct GitPanel {
     new_staged_count: usize,
     pending: Vec<PendingOperation>,
     pending_commit: Option<Task<()>>,
+    amend_pending: bool,
     pending_serialization: Task<Option<()>>,
     pub(crate) project: Entity<Project>,
     scroll_handle: UniformListScrollHandle,
@@ -492,6 +493,7 @@ impl GitPanel {
             new_staged_count: 0,
             pending: Vec::new(),
             pending_commit: None,
+            amend_pending: false,
             pending_serialization: Task::ready(None),
             single_staged_entry: None,
             single_tracked_entry: None,
@@ -1417,18 +1419,76 @@ impl GitPanel {
     }
 
     fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
+        if self.amend_pending {
+            return;
+        }
         if self
             .commit_editor
             .focus_handle(cx)
             .contains_focused(window, cx)
         {
             telemetry::event!("Git Committed", source = "Git Panel");
-            self.commit_changes(window, cx)
+            self.commit_changes(CommitOptions { amend: false }, window, cx)
+        } else {
+            cx.propagate();
+        }
+    }
+
+    fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(active_repository) = self.active_repository.as_ref() else {
+            return;
+        };
+        let Some(branch) = active_repository.read(cx).branch.as_ref() else {
+            return;
+        };
+        let Some(recent_sha) = branch
+            .most_recent_commit
+            .as_ref()
+            .map(|commit| commit.sha.to_string())
+        else {
+            return;
+        };
+        if self
+            .commit_editor
+            .focus_handle(cx)
+            .contains_focused(window, cx)
+        {
+            if !self.amend_pending {
+                self.amend_pending = true;
+                cx.notify();
+                if self.commit_editor.read(cx).is_empty(cx) {
+                    let detail_task = self.load_commit_details(recent_sha, cx);
+                    cx.spawn(async move |this, cx| {
+                        if let Ok(message) = detail_task.await.map(|detail| detail.message) {
+                            this.update(cx, |this, cx| {
+                                this.commit_message_buffer(cx).update(cx, |buffer, cx| {
+                                    let start = buffer.anchor_before(0);
+                                    let end = buffer.anchor_after(buffer.len());
+                                    buffer.edit([(start..end, message)], None, cx);
+                                });
+                            })
+                            .log_err();
+                        }
+                    })
+                    .detach();
+                }
+            } else {
+                telemetry::event!("Git Amended", source = "Git Panel");
+                self.amend_pending = false;
+                self.commit_changes(CommitOptions { amend: true }, window, cx);
+            }
         } else {
             cx.propagate();
         }
     }
 
+    fn cancel(&mut self, _: &git::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+        if self.amend_pending {
+            self.amend_pending = false;
+            cx.notify();
+        }
+    }
+
     fn custom_or_suggested_commit_message(&self, cx: &mut Context<Self>) -> Option<String> {
         let message = self.commit_editor.read(cx).text(cx);
 
@@ -1440,7 +1500,12 @@ impl GitPanel {
             .filter(|message| !message.trim().is_empty())
     }
 
-    pub(crate) fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+    pub(crate) fn commit_changes(
+        &mut self,
+        options: CommitOptions,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         let Some(active_repository) = self.active_repository.clone() else {
             return;
         };
@@ -1474,8 +1539,9 @@ impl GitPanel {
 
         let task = if self.has_staged_changes() {
             // Repository serializes all git operations, so we can just send a commit immediately
-            let commit_task =
-                active_repository.update(cx, |repo, cx| repo.commit(message.into(), None, cx));
+            let commit_task = active_repository.update(cx, |repo, cx| {
+                repo.commit(message.into(), None, options, cx)
+            });
             cx.background_spawn(async move { commit_task.await? })
         } else {
             let changed_files = self
@@ -1495,8 +1561,9 @@ impl GitPanel {
                 active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
             cx.spawn(async move |_, cx| {
                 stage_task.await?;
-                let commit_task = active_repository
-                    .update(cx, |repo, cx| repo.commit(message.into(), None, cx))?;
+                let commit_task = active_repository.update(cx, |repo, cx| {
+                    repo.commit(message.into(), None, options, cx)
+                })?;
                 commit_task.await?
             })
         };
@@ -2722,6 +2789,34 @@ impl GitPanel {
         }
     }
 
+    fn render_git_commit_menu(
+        &self,
+        id: impl Into<ElementId>,
+        keybinding_target: Option<FocusHandle>,
+    ) -> impl IntoElement {
+        PopoverMenu::new(id.into())
+            .trigger(
+                ui::ButtonLike::new_rounded_right("commit-split-button-right")
+                    .layer(ui::ElevationIndex::ModalSurface)
+                    .size(ui::ButtonSize::None)
+                    .child(
+                        div()
+                            .px_1()
+                            .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
+                    ),
+            )
+            .menu(move |window, cx| {
+                Some(ContextMenu::build(window, cx, |context_menu, _, _| {
+                    context_menu
+                        .when_some(keybinding_target.clone(), |el, keybinding_target| {
+                            el.context(keybinding_target.clone())
+                        })
+                        .action("Amend...", Amend.boxed_clone())
+                }))
+            })
+            .anchor(Corner::TopRight)
+    }
+
     pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
         if self.has_unstaged_conflicts() {
             (false, "You must resolve conflicts before committing")
@@ -2739,10 +2834,18 @@ impl GitPanel {
     }
 
     pub fn commit_button_title(&self) -> &'static str {
-        if self.has_staged_changes() {
-            "Commit"
+        if self.amend_pending {
+            if self.has_staged_changes() {
+                "Amend"
+            } else {
+                "Amend Tracked"
+            }
         } else {
-            "Commit Tracked"
+            if self.has_staged_changes() {
+                "Commit"
+            } else {
+                "Commit Tracked"
+            }
         }
     }
 
@@ -2885,6 +2988,10 @@ impl GitPanel {
         let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
             editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
         });
+        let has_previous_commit = branch
+            .as_ref()
+            .and_then(|branch| branch.most_recent_commit.as_ref())
+            .is_some();
 
         let footer = v_flex()
             .child(PanelRepoFooter::new(display_name, branch, Some(git_panel)))
@@ -2920,32 +3027,140 @@ impl GitPanel {
                                     .unwrap_or_else(|| div().into_any_element()),
                             )
                             .child(
-                                h_flex().gap_0p5().children(enable_coauthors).child(
-                                    panel_filled_button(title)
-                                        .tooltip(move |window, cx| {
-                                            if can_commit {
-                                                Tooltip::for_action_in(
-                                                    tooltip,
-                                                    &Commit,
-                                                    &commit_tooltip_focus_handle,
-                                                    window,
-                                                    cx,
+                                h_flex()
+                                    .gap_0p5()
+                                    .children(enable_coauthors)
+                                    .when(self.amend_pending, {
+                                        |this| {
+                                            this.h_flex()
+                                                .gap_1()
+                                                .child(
+                                                    panel_filled_button("Cancel")
+                                                        .tooltip({
+                                                            let handle =
+                                                                commit_tooltip_focus_handle.clone();
+                                                            move |window, cx| {
+                                                                Tooltip::for_action_in(
+                                                                    "Cancel amend",
+                                                                    &git::Cancel,
+                                                                    &handle,
+                                                                    window,
+                                                                    cx,
+                                                                )
+                                                            }
+                                                        })
+                                                        .on_click(move |_, window, cx| {
+                                                            window.dispatch_action(
+                                                                Box::new(git::Cancel),
+                                                                cx,
+                                                            );
+                                                        }),
                                                 )
-                                            } else {
-                                                Tooltip::simple(tooltip, cx)
-                                            }
+                                                .child(
+                                                    panel_filled_button(title)
+                                                        .tooltip({
+                                                            let handle =
+                                                                commit_tooltip_focus_handle.clone();
+                                                            move |window, cx| {
+                                                                if can_commit {
+                                                                    Tooltip::for_action_in(
+                                                                        tooltip, &Amend, &handle,
+                                                                        window, cx,
+                                                                    )
+                                                                } else {
+                                                                    Tooltip::simple(tooltip, cx)
+                                                                }
+                                                            }
+                                                        })
+                                                        .disabled(!can_commit || self.modal_open)
+                                                        .on_click(move |_, window, cx| {
+                                                            window.dispatch_action(
+                                                                Box::new(git::Amend),
+                                                                cx,
+                                                            );
+                                                        }),
+                                                )
+                                        }
+                                    })
+                                    .when(!self.amend_pending, |this| {
+                                        this.when(has_previous_commit, |this| {
+                                            this.child(SplitButton::new(
+                                                ui::ButtonLike::new_rounded_left(ElementId::Name(
+                                                    format!("split-button-left-{}", title).into(),
+                                                ))
+                                                .layer(ui::ElevationIndex::ModalSurface)
+                                                .size(ui::ButtonSize::Compact)
+                                                .child(
+                                                    div()
+                                                        .child(
+                                                            Label::new(title)
+                                                                .size(LabelSize::Small),
+                                                        )
+                                                        .mr_0p5(),
+                                                )
+                                                .on_click(move |_, window, cx| {
+                                                    window
+                                                        .dispatch_action(Box::new(git::Commit), cx);
+                                                })
+                                                .disabled(!can_commit || self.modal_open)
+                                                .tooltip({
+                                                    let handle =
+                                                        commit_tooltip_focus_handle.clone();
+                                                    move |window, cx| {
+                                                        if can_commit {
+                                                            Tooltip::with_meta_in(
+                                                                tooltip,
+                                                                Some(&git::Commit),
+                                                                "git commit",
+                                                                &handle.clone(),
+                                                                window,
+                                                                cx,
+                                                            )
+                                                        } else {
+                                                            Tooltip::simple(tooltip, cx)
+                                                        }
+                                                    }
+                                                }),
+                                                self.render_git_commit_menu(
+                                                    ElementId::Name(
+                                                        format!("split-button-right-{}", title)
+                                                            .into(),
+                                                    ),
+                                                    Some(commit_tooltip_focus_handle.clone()),
+                                                )
+                                                .into_any_element(),
+                                            ))
                                         })
-                                        .disabled(!can_commit || self.modal_open)
-                                        .on_click({
-                                            cx.listener(move |this, _: &ClickEvent, window, cx| {
-                                                telemetry::event!(
-                                                    "Git Committed",
-                                                    source = "Git Panel"
-                                                );
-                                                this.commit_changes(window, cx)
-                                            })
-                                        }),
-                                ),
+                                        .when(
+                                            !has_previous_commit,
+                                            |this| {
+                                                this.child(
+                                                    panel_filled_button(title)
+                                                        .tooltip(move |window, cx| {
+                                                            if can_commit {
+                                                                Tooltip::with_meta_in(
+                                                                    tooltip,
+                                                                    Some(&git::Commit),
+                                                                    "git commit",
+                                                                    &commit_tooltip_focus_handle,
+                                                                    window,
+                                                                    cx,
+                                                                )
+                                                            } else {
+                                                                Tooltip::simple(tooltip, cx)
+                                                            }
+                                                        })
+                                                        .disabled(!can_commit || self.modal_open)
+                                                        .on_click(move |_, window, cx| {
+                                                            window.dispatch_action(
+                                                                Box::new(git::Commit),
+                                                                cx,
+                                                            );
+                                                        }),
+                                                )
+                                            },
+                                        )
+                                    }),
                             ),
                     )
                     .child(
@@ -2994,6 +3209,17 @@ impl GitPanel {
         Some(footer)
     }
 
+    fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .py_2()
+            .px(px(8.))
+            .border_color(cx.theme().colors().border)
+            .child(
+                Label::new("Your changes will modify your most recent commit. If you want to make these changes as a new commit, you can cancel the amend operation.")
+                    .size(LabelSize::Small),
+            )
+    }
+
     fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
         let active_repository = self.active_repository.as_ref()?;
         let branch = active_repository.read(cx).branch.as_ref()?;
@@ -3448,7 +3674,7 @@ impl GitPanel {
             .into_any_element()
     }
 
-    fn load_commit_details(
+    pub fn load_commit_details(
         &self,
         sha: String,
         cx: &mut Context<Self>,
@@ -3766,6 +3992,14 @@ impl GitPanel {
     fn has_write_access(&self, cx: &App) -> bool {
         !self.project.read(cx).is_read_only(cx)
     }
+
+    pub fn amend_pending(&self) -> bool {
+        self.amend_pending
+    }
+
+    pub fn set_amend_pending(&mut self, value: bool) {
+        self.amend_pending = value;
+    }
 }
 
 fn current_language_model(cx: &Context<'_, GitPanel>) -> Option<Arc<dyn LanguageModel>> {
@@ -3806,6 +4040,8 @@ impl Render for GitPanel {
             .when(has_write_access && !project.is_read_only(cx), |this| {
                 this.on_action(cx.listener(Self::toggle_staged_for_selected))
                     .on_action(cx.listener(GitPanel::commit))
+                    .on_action(cx.listener(GitPanel::amend))
+                    .on_action(cx.listener(GitPanel::cancel))
                     .on_action(cx.listener(Self::stage_all))
                     .on_action(cx.listener(Self::unstage_all))
                     .on_action(cx.listener(Self::stage_selected))
@@ -3852,7 +4088,12 @@ impl Render for GitPanel {
                         }
                     })
                     .children(self.render_footer(window, cx))
-                    .children(self.render_previous_commit(cx))
+                    .when(self.amend_pending, |this| {
+                        this.child(self.render_pending_amend(cx))
+                    })
+                    .when(!self.amend_pending, |this| {
+                        this.children(self.render_previous_commit(cx))
+                    })
                     .into_any_element(),
             )
             .children(self.context_menu.as_ref().map(|(menu, position, _)| {

crates/git_ui/src/git_ui.rs 🔗

@@ -368,6 +368,7 @@ mod remote_button {
             })
             .anchor(Corner::TopRight)
     }
+
     #[allow(clippy::too_many_arguments)]
     fn split_button(
         id: SharedString,

crates/project/src/git_store.rs 🔗

@@ -21,7 +21,7 @@ use git::{
     blame::Blame,
     parse_git_remote_url,
     repository::{
-        Branch, CommitDetails, CommitDiff, CommitFile, DiffType, GitRepository,
+        Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, GitRepository,
         GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode,
         UpstreamTrackingStatus,
     },
@@ -1656,10 +1656,18 @@ impl GitStore {
         let message = SharedString::from(envelope.payload.message);
         let name = envelope.payload.name.map(SharedString::from);
         let email = envelope.payload.email.map(SharedString::from);
+        let options = envelope.payload.options.unwrap_or_default();
 
         repository_handle
             .update(&mut cx, |repository_handle, cx| {
-                repository_handle.commit(message, name.zip(email), cx)
+                repository_handle.commit(
+                    message,
+                    name.zip(email),
+                    CommitOptions {
+                        amend: options.amend,
+                    },
+                    cx,
+                )
             })?
             .await??;
         Ok(proto::Ack {})
@@ -3248,6 +3256,7 @@ impl Repository {
         &mut self,
         message: SharedString,
         name_and_email: Option<(SharedString, SharedString)>,
+        options: CommitOptions,
         _cx: &mut App,
     ) -> oneshot::Receiver<Result<()>> {
         let id = self.id;
@@ -3258,7 +3267,11 @@ impl Repository {
                     backend,
                     environment,
                     ..
-                } => backend.commit(message, name_and_email, environment).await,
+                } => {
+                    backend
+                        .commit(message, name_and_email, options, environment)
+                        .await
+                }
                 RepositoryState::Remote { project_id, client } => {
                     let (name, email) = name_and_email.unzip();
                     client
@@ -3268,6 +3281,9 @@ impl Repository {
                             message: String::from(message),
                             name: name.map(String::from),
                             email: email.map(String::from),
+                            options: Some(proto::commit::CommitOptions {
+                                amend: options.amend,
+                            }),
                         })
                         .await
                         .context("sending commit request")?;

crates/proto/proto/git.proto 🔗

@@ -292,6 +292,11 @@ message Commit {
     optional string name = 4;
     optional string email = 5;
     string message = 6;
+    optional CommitOptions options = 7;
+
+    message CommitOptions {
+        bool amend = 1;
+    }
 }
 
 message OpenCommitMessageBuffer {

crates/ui/src/components/button/split_button.rs 🔗

@@ -20,6 +20,12 @@ pub struct SplitButton {
     pub right: AnyElement,
 }
 
+impl SplitButton {
+    pub fn new(left: ButtonLike, right: AnyElement) -> Self {
+        Self { left, right }
+    }
+}
+
 impl RenderOnce for SplitButton {
     fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         h_flex()