git_ui: Add support for generating commit messages with an LLM (#26227)

Marshall Bowers and Conrad Irwin created

This PR finishes up the support for generating commit messages using an
LLM.

We're shelling out to `git diff` to get the diff text, as it seemed more
efficient than attempting to reconstruct the diff ourselves from our
internal Git state.


https://github.com/user-attachments/assets/9bcf30a7-7a08-4f49-a753-72a5d954bddd

Release Notes:

- Git Beta: Added support for generating commit messages using a
language model.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/default-linux.json           |   6 
assets/keymaps/default-macos.json           |   6 
crates/collab/src/rpc.rs                    |   1 
crates/git/src/git.rs                       |   3 
crates/git/src/repository.rs                |  34 ++++++
crates/git_ui/src/commit_message_prompt.txt |   8 +
crates/git_ui/src/commit_modal.rs           |  20 +++
crates/git_ui/src/git_panel.rs              | 120 +++++++++++++++-------
crates/project/src/git.rs                   |  62 +++++++++++
crates/proto/proto/zed.proto                |  21 +++
crates/proto/src/proto.rs                   |   4 
11 files changed, 233 insertions(+), 52 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -747,7 +747,8 @@
     "context": "GitCommit > Editor",
     "bindings": {
       "enter": "editor::Newline",
-      "ctrl-enter": "git::Commit"
+      "ctrl-enter": "git::Commit",
+      "alt-l": "git::GenerateCommitMessage"
     }
   },
   {
@@ -769,7 +770,8 @@
       "tab": "git_panel::FocusChanges",
       "shift-tab": "git_panel::FocusChanges",
       "ctrl-enter": "git::Commit",
-      "alt-up": "git_panel::FocusChanges"
+      "alt-up": "git_panel::FocusChanges",
+      "alt-l": "git::GenerateCommitMessage"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -786,7 +786,8 @@
       "tab": "git_panel::FocusChanges",
       "shift-tab": "git_panel::FocusChanges",
       "alt-up": "git_panel::FocusChanges",
-      "shift-escape": "git::ExpandCommitEditor"
+      "shift-escape": "git::ExpandCommitEditor",
+      "alt-tab": "git::GenerateCommitMessage"
     }
   },
   {
@@ -794,7 +795,8 @@
     "use_key_equivalents": true,
     "bindings": {
       "enter": "editor::Newline",
-      "cmd-enter": "git::Commit"
+      "cmd-enter": "git::Commit",
+      "alt-tab": "git::GenerateCommitMessage"
     }
   },
   {

crates/collab/src/rpc.rs 🔗

@@ -402,6 +402,7 @@ impl Server {
             .add_request_handler(forward_read_only_project_request::<proto::GitCheckoutFiles>)
             .add_request_handler(forward_mutating_project_request::<proto::SetIndexText>)
             .add_request_handler(forward_mutating_project_request::<proto::OpenCommitMessageBuffer>)
+            .add_request_handler(forward_mutating_project_request::<proto::GitDiff>)
             .add_request_handler(forward_mutating_project_request::<proto::GitCreateBranch>)
             .add_request_handler(forward_mutating_project_request::<proto::GitChangeBranch>)
             .add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)

crates/git/src/git.rs 🔗

@@ -49,7 +49,8 @@ actions!(
         Pull,
         Fetch,
         Commit,
-        ExpandCommitEditor
+        ExpandCommitEditor,
+        GenerateCommitMessage
     ]
 );
 action_with_deprecated_aliases!(git, RestoreFile, ["editor::RevertFile"]);

crates/git/src/repository.rs 🔗

@@ -217,6 +217,14 @@ pub trait GitRepository: Send + Sync {
 
     /// returns a list of remote branches that contain HEAD
     fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>>;
+
+    /// Run git diff
+    fn diff(&self, diff: DiffType) -> Result<String>;
+}
+
+pub enum DiffType {
+    HeadToIndex,
+    HeadToWorktree,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
@@ -577,6 +585,28 @@ impl GitRepository for RealGitRepository {
         )
     }
 
+    fn diff(&self, diff: DiffType) -> Result<String> {
+        let working_directory = self.working_directory()?;
+        let args = match diff {
+            DiffType::HeadToIndex => Some("--staged"),
+            DiffType::HeadToWorktree => None,
+        };
+
+        let output = new_std_command(&self.git_binary_path)
+            .current_dir(&working_directory)
+            .args(["diff"])
+            .args(args)
+            .output()?;
+
+        if !output.status.success() {
+            return Err(anyhow!(
+                "Failed to run git diff:\n{}",
+                String::from_utf8_lossy(&output.stderr)
+            ));
+        }
+        Ok(String::from_utf8_lossy(&output.stdout).to_string())
+    }
+
     fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
         let working_directory = self.working_directory()?;
 
@@ -1048,6 +1078,10 @@ impl GitRepository for FakeGitRepository {
     fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>> {
         unimplemented!()
     }
+
+    fn diff(&self, _diff: DiffType) -> Result<String> {
+        unimplemented!()
+    }
 }
 
 fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {

crates/git_ui/src/commit_message_prompt.txt 🔗

@@ -1,4 +1,8 @@
-You are an expert at writing Git commits. Your job is to write a clear commit message that summarizes the changes.
+You are an expert at writing Git commits. Your job is to write a short clear commit message that summarizes the changes.
+
+If you can accurately express the change in just the subject line, don't include anything in the message body. Only use the body when it is providing *useful* information.
+
+Don't repeat information from the subject line in the message body.
 
 Only return the commit message in your response. Do not include any additional meta-commentary about the task.
 
@@ -10,6 +14,6 @@ Follow good Git style:
 - Do not end the subject line with any punctuation
 - Use the imperative mood in the subject line
 - Wrap the body at 72 characters
-- Use the body to explain *what* and *why* vs. *how*
+- Keep the body short and concise (omit it entirely if not useful)
 
 Here are the changes in this commit:

crates/git_ui/src/commit_modal.rs 🔗

@@ -234,7 +234,7 @@ impl CommitModal {
     pub fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let git_panel = self.git_panel.clone();
 
-        let (branch, can_commit, tooltip, commit_label, co_authors) =
+        let (branch, can_commit, tooltip, commit_label, co_authors, generate_commit_message) =
             self.git_panel.update(cx, |git_panel, cx| {
                 let branch = git_panel
                     .active_repository
@@ -249,7 +249,15 @@ impl CommitModal {
                 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);
-                (branch, can_commit, tooltip, title, co_authors)
+                let generate_commit_message = git_panel.render_generate_commit_message_button(cx);
+                (
+                    branch,
+                    can_commit,
+                    tooltip,
+                    title,
+                    co_authors,
+                    generate_commit_message,
+                )
             });
 
         let branch_picker_button = panel_button(branch)
@@ -316,7 +324,13 @@ impl CommitModal {
             .w_full()
             .h(px(self.properties.footer_height))
             .gap_1()
-            .child(h_flex().gap_1().child(branch_picker).children(co_authors))
+            .child(
+                h_flex()
+                    .gap_1()
+                    .child(branch_picker)
+                    .children(co_authors)
+                    .child(generate_commit_message),
+            )
             .child(div().flex_1())
             .child(
                 h_flex()

crates/git_ui/src/git_panel.rs 🔗

@@ -18,8 +18,8 @@ use editor::{
 };
 use futures::StreamExt as _;
 use git::repository::{
-    Branch, CommitDetails, CommitSummary, PushOptions, Remote, RemoteCommandOutput, ResetMode,
-    Upstream, UpstreamTracking, UpstreamTrackingStatus,
+    Branch, CommitDetails, CommitSummary, DiffType, PushOptions, Remote, RemoteCommandOutput,
+    ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus,
 };
 use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
 use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
@@ -1393,6 +1393,15 @@ impl GitPanel {
         Some(format!("{} {}", action_text, file_name))
     }
 
+    fn generate_commit_message_action(
+        &mut self,
+        _: &git::GenerateCommitMessage,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.generate_commit_message(cx);
+    }
+
     /// Generates a commit message using an LLM.
     fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
         let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else {
@@ -1406,38 +1415,50 @@ impl GitPanel {
             return;
         }
 
-        const PROMPT: &str = include_str!("commit_message_prompt.txt");
-
-        // TODO: We need to generate a diff from the actual Git state.
-        //
-        // It need not look exactly like the structure below, this is just an example generated by Claude.
-        let diff_text = "diff --git a/src/main.rs b/src/main.rs
-index 1234567..abcdef0 100644
---- a/src/main.rs
-+++ b/src/main.rs
-@@ -10,7 +10,7 @@ fn main() {
-     println!(\"Hello, world!\");
--    let unused_var = 42;
-+    let important_value = 42;
-
-     // Do something with the value
--    // TODO: Implement this later
-+    println!(\"The answer is {}\", important_value);
- }";
-
-        let request = LanguageModelRequest {
-            messages: vec![LanguageModelRequestMessage {
-                role: Role::User,
-                content: vec![format!("{PROMPT}\n{diff_text}").into()],
-                cache: false,
-            }],
-            tools: Vec::new(),
-            stop: Vec::new(),
-            temperature: None,
+        let Some(repo) = self.active_repository.as_ref() else {
+            return;
         };
 
+        let diff = repo.update(cx, |repo, cx| {
+            if self.has_staged_changes() {
+                repo.diff(DiffType::HeadToIndex, cx)
+            } else {
+                repo.diff(DiffType::HeadToWorktree, cx)
+            }
+        });
+
         self.generate_commit_message_task = Some(cx.spawn(|this, mut cx| {
             async move {
+                let _defer = util::defer({
+                    let mut cx = cx.clone();
+                    let this = this.clone();
+                    move || {
+                        this.update(&mut cx, |this, _cx| {
+                            this.generate_commit_message_task.take();
+                        })
+                        .ok();
+                    }
+                });
+
+                let mut diff_text = diff.await??;
+                const ONE_MB: usize = 1_000_000;
+                if diff_text.len() > ONE_MB {
+                    diff_text = diff_text.chars().take(ONE_MB).collect()
+                }
+
+                const PROMPT: &str = include_str!("commit_message_prompt.txt");
+
+                let request = LanguageModelRequest {
+                    messages: vec![LanguageModelRequestMessage {
+                        role: Role::User,
+                        content: vec![format!("{PROMPT}\n{diff_text}").into()],
+                        cache: false,
+                    }],
+                    tools: Vec::new(),
+                    stop: Vec::new(),
+                    temperature: None,
+                };
+
                 let stream = model.stream_completion_text(request, &cx);
                 let mut messages = stream.await?;
 
@@ -2113,6 +2134,32 @@ index 1234567..abcdef0 100644
         self.has_staged_changes()
     }
 
+    pub(crate) fn render_generate_commit_message_button(&self, cx: &Context<Self>) -> AnyElement {
+        if self.generate_commit_message_task.is_some() {
+            return Icon::new(IconName::ArrowCircle)
+                .size(IconSize::XSmall)
+                .color(Color::Info)
+                .with_animation(
+                    "arrow-circle",
+                    Animation::new(Duration::from_secs(2)).repeat(),
+                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+                )
+                .into_any_element();
+        }
+
+        IconButton::new("generate-commit-message", IconName::ZedAssistant)
+            .shape(ui::IconButtonShape::Square)
+            .tooltip(Tooltip::for_action_title_in(
+                "Generate commit message",
+                &git::GenerateCommitMessage,
+                &self.commit_editor.focus_handle(cx),
+            ))
+            .on_click(cx.listener(move |this, _event, _window, cx| {
+                this.generate_commit_message(cx);
+            }))
+            .into_any_element()
+    }
+
     pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
         let potential_co_authors = self.potential_co_authors(cx);
         if potential_co_authors.is_empty() {
@@ -2201,8 +2248,6 @@ index 1234567..abcdef0 100644
         let panel_editor_style = panel_editor_style(true, window, cx);
 
         let enable_coauthors = self.render_co_authors(cx);
-        // Note: This is hard-coded to `false` as it is not fully implemented.
-        let show_generate_commit_message_button = false;
 
         let title = self.commit_button_title();
         let editor_focus_handle = self.commit_editor.focus_handle(cx);
@@ -2253,16 +2298,8 @@ index 1234567..abcdef0 100644
                             .gap_0p5()
                             .h(footer_size)
                             .flex_none()
-                            .when(show_generate_commit_message_button, |parent| {
-                                parent.child(
-                                    panel_filled_button("Generate Commit Message").on_click(
-                                        cx.listener(move |this, _event, _window, cx| {
-                                            this.generate_commit_message(cx);
-                                        }),
-                                    ),
-                                )
-                            })
                             .children(enable_coauthors)
+                            .child(self.render_generate_commit_message_button(cx))
                             .child(
                                 panel_filled_button(title)
                                     .tooltip(move |window, cx| {
@@ -2927,6 +2964,7 @@ impl Render for GitPanel {
             .on_action(cx.listener(Self::restore_tracked_files))
             .on_action(cx.listener(Self::clean_all))
             .on_action(cx.listener(Self::expand_commit_editor))
+            .on_action(cx.listener(Self::generate_commit_message_action))
             .when(has_write_access && has_co_authors, |git_panel| {
                 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
             })

crates/project/src/git.rs 🔗

@@ -12,6 +12,7 @@ use futures::{
     channel::{mpsc, oneshot},
     StreamExt as _,
 };
+use git::repository::DiffType;
 use git::{
     repository::{
         Branch, CommitDetails, GitRepository, PushOptions, Remote, RemoteCommandOutput, RepoPath,
@@ -136,6 +137,7 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_set_index_text);
         client.add_entity_request_handler(Self::handle_askpass);
         client.add_entity_request_handler(Self::handle_check_for_pushed_commits);
+        client.add_entity_request_handler(Self::handle_git_diff);
     }
 
     pub fn active_repository(&self) -> Option<Entity<Repository>> {
@@ -807,6 +809,33 @@ impl GitStore {
         })
     }
 
+    async fn handle_git_diff(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitDiff>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GitDiffResponse> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
+        let repository_handle =
+            Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
+        let diff_type = match envelope.payload.diff_type() {
+            proto::git_diff::DiffType::HeadToIndex => DiffType::HeadToIndex,
+            proto::git_diff::DiffType::HeadToWorktree => DiffType::HeadToWorktree,
+        };
+
+        let mut diff = repository_handle
+            .update(&mut cx, |repository_handle, cx| {
+                repository_handle.diff(diff_type, cx)
+            })?
+            .await??;
+        const ONE_MB: usize = 1_000_000;
+        if diff.len() > ONE_MB {
+            diff = diff.chars().take(ONE_MB).collect()
+        }
+
+        Ok(proto::GitDiffResponse { diff })
+    }
+
     fn repository_for_request(
         this: &Entity<Self>,
         worktree_id: WorktreeId,
@@ -1627,6 +1656,39 @@ impl Repository {
         })
     }
 
+    pub fn diff(&self, diff_type: DiffType, _cx: &App) -> oneshot::Receiver<Result<String>> {
+        self.send_job(|repo| async move {
+            match repo {
+                GitRepo::Local(git_repository) => git_repository.diff(diff_type),
+                GitRepo::Remote {
+                    project_id,
+                    client,
+                    worktree_id,
+                    work_directory_id,
+                    ..
+                } => {
+                    let response = client
+                        .request(proto::GitDiff {
+                            project_id: project_id.0,
+                            worktree_id: worktree_id.to_proto(),
+                            work_directory_id: work_directory_id.to_proto(),
+                            diff_type: match diff_type {
+                                DiffType::HeadToIndex => {
+                                    proto::git_diff::DiffType::HeadToIndex.into()
+                                }
+                                DiffType::HeadToWorktree => {
+                                    proto::git_diff::DiffType::HeadToWorktree.into()
+                                }
+                            },
+                        })
+                        .await?;
+
+                    Ok(response.diff)
+                }
+            }
+        })
+    }
+
     pub fn create_branch(&self, branch_name: String) -> oneshot::Receiver<Result<()>> {
         self.send_job(|repo| async move {
             match repo {

crates/proto/proto/zed.proto 🔗

@@ -342,7 +342,10 @@ message Envelope {
         CheckForPushedCommitsResponse check_for_pushed_commits_response = 316;
 
         AskPassRequest ask_pass_request = 317;
-        AskPassResponse ask_pass_response = 318; // current max
+        AskPassResponse ask_pass_response = 318;
+
+        GitDiff git_diff = 319;
+        GitDiffResponse git_diff_response = 320; // current max
     }
 
     reserved 87 to 88;
@@ -2907,3 +2910,19 @@ message CheckForPushedCommits {
 message CheckForPushedCommitsResponse {
   repeated string pushed_to = 1;
 }
+
+message GitDiff {
+    uint64 project_id = 1;
+    uint64 worktree_id = 2;
+    uint64 work_directory_id = 3;
+    DiffType diff_type = 4;
+
+    enum DiffType {
+        HEAD_TO_WORKTREE = 0;
+        HEAD_TO_INDEX = 1;
+    }
+}
+
+message GitDiffResponse {
+    string diff = 1;
+}

crates/proto/src/proto.rs 🔗

@@ -458,6 +458,8 @@ messages!(
     (GitChangeBranch, Background),
     (CheckForPushedCommits, Background),
     (CheckForPushedCommitsResponse, Background),
+    (GitDiff, Background),
+    (GitDiffResponse, Background),
 );
 
 request_messages!(
@@ -604,6 +606,7 @@ request_messages!(
     (GitCreateBranch, Ack),
     (GitChangeBranch, Ack),
     (CheckForPushedCommits, CheckForPushedCommitsResponse),
+    (GitDiff, GitDiffResponse),
 );
 
 entity_messages!(
@@ -709,6 +712,7 @@ entity_messages!(
     GitChangeBranch,
     GitCreateBranch,
     CheckForPushedCommits,
+    GitDiff,
 );
 
 entity_messages!(