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

@ -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"
}
},
{

View file

@ -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"
}
},
{

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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