diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 0a4df0747d..883bb6016c 100644 --- a/assets/keymaps/default-linux.json +++ b/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" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index fcdb8aa42c..ccada4add7 100644 --- a/assets/keymaps/default-macos.json +++ b/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" } }, { diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 1665945dce..febfe8a231 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -402,6 +402,7 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 99d3d744a0..d5f2136df0 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -49,7 +49,8 @@ actions!( Pull, Fetch, Commit, - ExpandCommitEditor + ExpandCommitEditor, + GenerateCommitMessage ] ); action_with_deprecated_aliases!(git, RestoreFile, ["editor::RevertFile"]); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 834f55bd33..4b3d223fc7 100644 --- a/crates/git/src/repository.rs +++ b/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>; + + /// Run git diff + fn diff(&self, diff: DiffType) -> Result; +} + +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 { + 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> { unimplemented!() } + + fn diff(&self, _diff: DiffType) -> Result { + unimplemented!() + } } fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { diff --git a/crates/git_ui/src/commit_message_prompt.txt b/crates/git_ui/src/commit_message_prompt.txt index 56ad556868..78b31e38dd 100644 --- a/crates/git_ui/src/commit_message_prompt.txt +++ b/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: diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index ea8583e689..137ca8dcec 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -234,7 +234,7 @@ impl CommitModal { pub fn render_footer(&self, window: &mut Window, cx: &mut Context) -> 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() diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 4c2b12ad2a..3a7b711b93 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/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.generate_commit_message(cx); + } + /// Generates a commit message using an LLM. fn generate_commit_message(&mut self, cx: &mut Context) { 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) -> 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) -> Option { 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)) }) diff --git a/crates/project/src/git.rs b/crates/project/src/git.rs index 62f5e095a6..a67ad39047 100644 --- a/crates/project/src/git.rs +++ b/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> { @@ -807,6 +809,33 @@ impl GitStore { }) } + async fn handle_git_diff( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + 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, worktree_id: WorktreeId, @@ -1627,6 +1656,39 @@ impl Repository { }) } + pub fn diff(&self, diff_type: DiffType, _cx: &App) -> oneshot::Receiver> { + 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> { self.send_job(|repo| async move { match repo { diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index c4ba7a92a0..88bc218dc5 100644 --- a/crates/proto/proto/zed.proto +++ b/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; +} diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index fdcd8093ca..ac0a290c43 100644 --- a/crates/proto/src/proto.rs +++ b/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!(