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 <noreply@anthropic.com>
This commit is contained in:
Richard Feldman 2025-12-15 21:57:19 -05:00 committed by GitHub
parent b17b097204
commit c7d248329b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 90 additions and 20 deletions

1
Cargo.lock generated
View file

@ -7077,6 +7077,7 @@ dependencies = [
"picker",
"pretty_assertions",
"project",
"prompt_store",
"rand 0.9.2",
"recent_projects",
"remote",

View file

@ -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<Utc>,
}
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,
}

View file

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

View file

@ -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<Project>,
repo_work_dir: &Arc<Path>,
cx: &mut AsyncApp,
) -> Option<String> {
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<Self>) {
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}")
} else {
format!("{PROMPT}\nHere is the user's subject line:\n{subject}\nHere are the changes in this commit:\n{diff_text}\n")
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\
<project_rules>\n{rules}\n</project_rules>\n"
),
None => String::new(),
};
const PROMPT: &str = include_str!("commit_message_prompt.txt");
let subject_section = if text_empty {
String::new()
} else {
format!("\nHere is the user's subject line:\n{subject}")
};
let content = format!(
"{PROMPT}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
);
let request = LanguageModelRequest {
thread_id: None,

View file

@ -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<WorktreeContext>,