From c7d248329bd28fee61e5c5c6915bce72687c066b Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 15 Dec 2025 21:57:19 -0500 Subject: [PATCH] Include project rules in commit message generation (#44921) Closes #38027 Release Notes: - AI-generated commit messages now respect rules files (e.g. `AGENTS.md`) if present --------- Co-authored-by: Claude Haiku 4.5 --- Cargo.lock | 1 + crates/agent/src/agent.rs | 15 +----- crates/git_ui/Cargo.toml | 1 + crates/git_ui/src/git_panel.rs | 79 +++++++++++++++++++++++++++--- crates/prompt_store/src/prompts.rs | 12 +++++ 5 files changed, 89 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c1ade1714c9dd7f609582cc8bdf5184678afcd9..b43be6986b89bcd121416ded247e5bd944628cca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7077,6 +7077,7 @@ dependencies = [ "picker", "pretty_assertions", "project", + "prompt_store", "rand 0.9.2", "recent_projects", "remote", diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 092f735bb7c3713e70ea137c2bab485315aa8849..715c8682ba9b1e6d21c6558271c00180691f59f0 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -33,7 +33,8 @@ use gpui::{ use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry}; use project::{Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::{ - ProjectContext, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext, + ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, + WorktreeContext, }; use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, update_settings_file}; @@ -51,18 +52,6 @@ pub struct ProjectSnapshot { pub timestamp: DateTime, } -const RULES_FILE_NAMES: [&str; 9] = [ - ".rules", - ".cursorrules", - ".windsurfrules", - ".clinerules", - ".github/copilot-instructions.md", - "CLAUDE.md", - "AGENT.md", - "AGENTS.md", - "GEMINI.md", -]; - pub struct RulesLoadingError { pub message: SharedString, } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 6747daa09d2801ad8c05c17fb04cb3ab235cdbff..c88244a036767be0ef862e74faa2113d54125443 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -43,6 +43,7 @@ notifications.workspace = true panel.workspace = true picker.workspace = true project.workspace = true +prompt_store.workspace = true recent_projects.workspace = true remote.workspace = true schemars.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index d0618508ddbd153f4dcb1b77e974dc42ed2b0b32..362423b79fed0e8f3428d6784dd6f15b47708247 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -57,6 +57,7 @@ use project::{ git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op}, project_settings::{GitPathStyle, ProjectSettings}, }; +use prompt_store::RULES_FILE_NAMES; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore, StatusStyle}; use std::future::Future; @@ -71,7 +72,7 @@ use ui::{ prelude::*, }; use util::paths::PathStyle; -use util::{ResultExt, TryFutureExt, maybe}; +use util::{ResultExt, TryFutureExt, maybe, rel_path::RelPath}; use workspace::SERIALIZATION_THROTTLE_TIME; use workspace::{ Workspace, @@ -2325,6 +2326,56 @@ impl GitPanel { compressed } + async fn load_project_rules( + project: &Entity, + repo_work_dir: &Arc, + cx: &mut AsyncApp, + ) -> Option { + let rules_path = cx + .update(|cx| { + for worktree in project.read(cx).worktrees(cx) { + let worktree_abs_path = worktree.read(cx).abs_path(); + if !worktree_abs_path.starts_with(&repo_work_dir) { + continue; + } + + let worktree_snapshot = worktree.read(cx).snapshot(); + for rules_name in RULES_FILE_NAMES { + if let Ok(rel_path) = RelPath::unix(rules_name) { + if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) { + if entry.is_file() { + return Some(ProjectPath { + worktree_id: worktree.read(cx).id(), + path: entry.path.clone(), + }); + } + } + } + } + } + None + }) + .ok()??; + + let buffer = project + .update(cx, |project, cx| project.open_buffer(rules_path, cx)) + .ok()? + .await + .ok()?; + + let content = buffer + .read_with(cx, |buffer, _| buffer.text()) + .ok()? + .trim() + .to_string(); + + if content.is_empty() { + None + } else { + Some(content) + } + } + /// Generates a commit message using an LLM. pub fn generate_commit_message(&mut self, cx: &mut Context) { if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) { @@ -2352,8 +2403,10 @@ impl GitPanel { }); let temperature = AgentSettings::temperature_for_model(&model, cx); + let project = self.project.clone(); + let repo_work_dir = repo.read(cx).work_directory_abs_path.clone(); - self.generate_commit_message_task = Some(cx.spawn(async move |this, cx| { + self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| { async move { let _defer = cx.on_drop(&this, |this, _cx| { this.generate_commit_message_task.take(); @@ -2386,19 +2439,33 @@ impl GitPanel { const MAX_DIFF_BYTES: usize = 20_000; diff_text = Self::compress_commit_diff(&diff_text, MAX_DIFF_BYTES); + let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await; + let subject = this.update(cx, |this, cx| { this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default() })?; let text_empty = subject.trim().is_empty(); - let content = if text_empty { - format!("{PROMPT}\nHere are the changes in this commit:\n{diff_text}") + const PROMPT: &str = include_str!("commit_message_prompt.txt"); + + let rules_section = match &rules_content { + Some(rules) => format!( + "\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\ + \n{rules}\n\n" + ), + None => String::new(), + }; + + let subject_section = if text_empty { + String::new() } else { - format!("{PROMPT}\nHere is the user's subject line:\n{subject}\nHere are the changes in this commit:\n{diff_text}\n") + format!("\nHere is the user's subject line:\n{subject}") }; - const PROMPT: &str = include_str!("commit_message_prompt.txt"); + let content = format!( + "{PROMPT}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}" + ); let request = LanguageModelRequest { thread_id: None, diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index 847e45742db17fe194d002c26a67380390b68f06..674d4869e9825fd700dde3db510fbf68c6b4d5cc 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -20,6 +20,18 @@ use util::{ use crate::UserPromptId; +pub const RULES_FILE_NAMES: &[&str] = &[ + ".rules", + ".cursorrules", + ".windsurfrules", + ".clinerules", + ".github/copilot-instructions.md", + "CLAUDE.md", + "AGENT.md", + "AGENTS.md", + "GEMINI.md", +]; + #[derive(Default, Debug, Clone, Serialize)] pub struct ProjectContext { pub worktrees: Vec,