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

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