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

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>
This commit is contained in:
Marshall Bowers 2025-03-06 14:47:52 -05:00 committed by GitHub
parent d1cec209d4
commit b8a8b9c699
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 233 additions and 52 deletions

View file

@ -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:

View file

@ -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()

View file

@ -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))
})