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:
parent
d1cec209d4
commit
b8a8b9c699
11 changed files with 233 additions and 52 deletions
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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>)
|
||||
|
|
|
@ -49,7 +49,8 @@ actions!(
|
|||
Pull,
|
||||
Fetch,
|
||||
Commit,
|
||||
ExpandCommitEditor
|
||||
ExpandCommitEditor,
|
||||
GenerateCommitMessage
|
||||
]
|
||||
);
|
||||
action_with_deprecated_aliases!(git, RestoreFile, ["editor::RevertFile"]);
|
||||
|
|
|
@ -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<()> {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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!(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue