Skip to content

Commit 07a9a7c

Browse files
committed
✨ feat: add automatic conventional commit style detection
Implement local commit history analysis to detect and apply conventional commit format when no explicit style is configured. New commit_style module provides pattern matching against recent commit history with confidence thresholds. When 3+ of 4+ commits match conventional format (type[scope]: description), Iris injects style instructions to use the same format. GitCommitService no longer applies gitmoji post-processing, allowing agent-generated messages to remain exactly as formatted. The use_gitmoji parameter is retained for API compatibility but no longer modifies commit messages. Git tools extended with range-based commit queries (from/to parameters) and contributor filtering to support style analysis and release notes generation. The detection skips merge commits, fixups, and squashes to avoid false positives from non-conventional maintenance commits.
1 parent 39e5742 commit 07a9a7c

8 files changed

Lines changed: 277 additions & 30 deletions

File tree

src/agents/capabilities/release_notes.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Organize your release notes naturally. A typical structure might include:
4646
- Agent Platform, API Changes, Performance, Developer Experience, etc.
4747
- **Breaking Changes** — Clear impact statements and migration guidance
4848
- **Upgrade Notes** — Actionable steps for users updating
49+
- **Contributors** — Include a short list of unique human contributors when there is more than one, or when the collaboration is worth calling out
4950
5051
Adapt the structure based on what makes sense for this specific release. Let the content drive the organization.
5152
@@ -59,6 +60,8 @@ Adapt the structure based on what makes sense for this specific release. Let the
5960
- Avoid cliché words: "enhance", "streamline", "leverage", "utilize", "robust"
6061
- Be precise. If context is incomplete, gather more evidence and clearly separate verified facts from inference.
6162
- Only describe what's evident from the diffs and commits
63+
- Exclude bot identities from contributor callouts (`[bot]`, `dependabot`, `renovate`, `github-actions`)
64+
- Omit the contributor section when there is only one human contributor or the list would add no value
6265
6366
## Example Format
6467

src/agents/commit_style.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
use crate::context::RecentCommit;
2+
use regex::Regex;
3+
use std::sync::LazyLock;
4+
5+
const MIN_CONVENTIONAL_SAMPLES: usize = 4;
6+
const MIN_CONVENTIONAL_MATCHES: usize = 3;
7+
const MIN_CONVENTIONAL_CONFIDENCE_NUMERATOR: usize = 3;
8+
const MIN_CONVENTIONAL_CONFIDENCE_DENOMINATOR: usize = 5;
9+
10+
static CONVENTIONAL_COMMIT_RE: LazyLock<Regex> = LazyLock::new(|| {
11+
Regex::new(r"^[a-z][a-z0-9-]*(\([^)]+\))?!?: [^\s].+$")
12+
.expect("conventional commit regex must compile")
13+
});
14+
15+
#[derive(Debug, Clone, PartialEq, Eq)]
16+
pub(crate) struct ConventionalCommitStyle {
17+
examples: Vec<String>,
18+
}
19+
20+
impl ConventionalCommitStyle {
21+
#[must_use]
22+
pub(crate) fn examples(&self) -> &[String] {
23+
&self.examples
24+
}
25+
}
26+
27+
#[must_use]
28+
pub(crate) fn detect_conventional_commit_style(
29+
commits: &[RecentCommit],
30+
) -> Option<ConventionalCommitStyle> {
31+
let subjects: Vec<&str> = commits
32+
.iter()
33+
.map(|commit| first_subject_line(&commit.message))
34+
.filter(|subject| !should_ignore_subject(subject))
35+
.collect();
36+
37+
if subjects.len() < MIN_CONVENTIONAL_SAMPLES {
38+
return None;
39+
}
40+
41+
let matching_subjects: Vec<&str> = subjects
42+
.iter()
43+
.copied()
44+
.filter(|subject| CONVENTIONAL_COMMIT_RE.is_match(subject))
45+
.collect();
46+
47+
if matching_subjects.len() < MIN_CONVENTIONAL_MATCHES
48+
|| matching_subjects.len() * MIN_CONVENTIONAL_CONFIDENCE_DENOMINATOR
49+
< subjects.len() * MIN_CONVENTIONAL_CONFIDENCE_NUMERATOR
50+
{
51+
return None;
52+
}
53+
54+
Some(ConventionalCommitStyle {
55+
examples: matching_subjects
56+
.into_iter()
57+
.take(3)
58+
.map(ToOwned::to_owned)
59+
.collect(),
60+
})
61+
}
62+
63+
fn first_subject_line(message: &str) -> &str {
64+
message.lines().next().unwrap_or_default().trim()
65+
}
66+
67+
fn should_ignore_subject(subject: &str) -> bool {
68+
let normalized = subject.trim().to_ascii_lowercase();
69+
70+
normalized.is_empty()
71+
|| normalized.starts_with("merge ")
72+
|| normalized.starts_with("fixup!")
73+
|| normalized.starts_with("squash!")
74+
}

src/agents/iris.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use std::borrow::Cow;
1313
use std::collections::HashMap;
1414
use std::fmt;
1515

16+
use crate::agents::commit_style::{ConventionalCommitStyle, detect_conventional_commit_style};
17+
1618
/// Macro to build a streaming agent for any provider.
1719
///
1820
/// All three providers (`OpenAI`, `Anthropic`, `Gemini`) share identical setup logic —
@@ -815,7 +817,8 @@ Guidelines:
815817
// Handle commit-specific styling (structured JSON output with emoji field)
816818
if capability == "commit" {
817819
if use_style_detection {
818-
tracing::info!("🔍 Using local commit style detection (default mode)");
820+
tracing::info!("🔍 Running local commit style detection (default mode)");
821+
Self::inject_detected_commit_style(system_prompt);
819822
} else if commit_emoji {
820823
system_prompt.push_str("\n\n=== GITMOJI INSTRUCTIONS ===\n");
821824
system_prompt.push_str("Set the 'emoji' field to a single relevant gitmoji. ");
@@ -856,6 +859,41 @@ Guidelines:
856859
}
857860
}
858861

862+
fn inject_detected_commit_style(prompt: &mut String) {
863+
let Some(style) = Self::detect_local_conventional_commit_style() else {
864+
tracing::info!("🔍 No high-confidence conventional commit pattern detected locally");
865+
return;
866+
};
867+
868+
tracing::info!("📋 Detected conventional commit history locally");
869+
prompt.push_str("\n\n=== STYLE INSTRUCTIONS ===\n");
870+
prompt.push_str(
871+
"Detected repository commit format from recent history: Conventional Commits without emojis.\n",
872+
);
873+
prompt.push_str(
874+
"Use `<type>[optional scope]: <description>` with a lowercase type, optional scope, and imperative description. ",
875+
);
876+
prompt.push_str(
877+
"Set the `emoji` field to null and do not include emojis anywhere in the title or body.\n",
878+
);
879+
880+
if !style.examples().is_empty() {
881+
prompt.push_str("Observed examples:\n");
882+
for example in style.examples() {
883+
prompt.push_str("- `");
884+
prompt.push_str(example);
885+
prompt.push_str("`\n");
886+
}
887+
}
888+
}
889+
890+
fn detect_local_conventional_commit_style() -> Option<ConventionalCommitStyle> {
891+
let current_dir = std::env::current_dir().ok()?;
892+
let repo = crate::git::GitRepo::new(&current_dir).ok()?;
893+
let commits = repo.get_recent_commits(12).ok()?;
894+
detect_conventional_commit_style(&commits)
895+
}
896+
859897
fn inject_pr_review_emoji_styling(prompt: &mut String) {
860898
prompt.push_str("\n\n=== EMOJI STYLING ===\n");
861899
prompt.push_str("Use emojis to make the output visually scannable and engaging:\n");

src/agents/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! including setup, core types, and execution management.
55
66
// Core agent components
7+
mod commit_style;
78
pub mod context;
89
pub mod core;
910
pub mod iris;

src/agents/tools/git.rs

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ use anyhow::Result;
66
use rig::completion::ToolDefinition;
77
use rig::tool::Tool;
88
use serde::{Deserialize, Serialize};
9+
use std::collections::BTreeSet;
910

10-
use crate::context::ChangeType;
11+
use crate::context::{ChangeType, RecentCommit};
1112
use crate::define_tool_error;
1213
use crate::git::StagedFile;
1314

@@ -500,6 +501,10 @@ pub struct GitLog;
500501
pub struct GitLogArgs {
501502
#[serde(default)]
502503
pub count: Option<usize>,
504+
#[serde(default)]
505+
pub from: Option<String>,
506+
#[serde(default)]
507+
pub to: Option<String>,
503508
}
504509

505510
impl Tool for GitLog {
@@ -519,22 +524,75 @@ impl Tool for GitLog {
519524
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
520525
let repo = get_current_repo().map_err(GitError::from)?;
521526

527+
if let Some(from) = args.from {
528+
let to = args.to.unwrap_or_else(|| "HEAD".to_string());
529+
let commits = repo
530+
.get_commits_in_range(&from, &to)
531+
.map_err(GitError::from)?;
532+
return Ok(format_git_log_output(
533+
&format!("Commits from {from} to {to}:"),
534+
&commits,
535+
true,
536+
));
537+
}
538+
539+
if args.to.is_some() {
540+
return Err(GitError::from(anyhow::anyhow!(
541+
"git_log requires `from` when `to` is provided"
542+
)));
543+
}
544+
522545
let commits = repo
523546
.get_recent_commits(args.count.unwrap_or(10))
524547
.map_err(GitError::from)?;
525548

526-
let mut output = String::new();
527-
output.push_str("Recent commits:\n");
549+
Ok(format_git_log_output("Recent commits:", &commits, false))
550+
}
551+
}
528552

529-
for commit in commits {
530-
output.push_str(&format!(
531-
"{}: {} ({})\n",
532-
commit.hash, commit.message, commit.author
533-
));
534-
}
553+
fn format_git_log_output(
554+
header: &str,
555+
commits: &[RecentCommit],
556+
include_contributors: bool,
557+
) -> String {
558+
let mut output = String::new();
559+
output.push_str(header);
560+
output.push('\n');
535561

536-
Ok(output)
562+
for commit in commits {
563+
let title = commit.message.lines().next().unwrap_or_default().trim();
564+
output.push_str(&format!("{}: {} ({})\n", commit.hash, title, commit.author));
537565
}
566+
567+
if include_contributors {
568+
let contributors: BTreeSet<String> = commits
569+
.iter()
570+
.map(|commit| commit.author.trim())
571+
.filter(|author| !author.is_empty() && !is_bot_author(author))
572+
.map(ToOwned::to_owned)
573+
.collect();
574+
575+
if !contributors.is_empty() {
576+
output.push_str("\nContributors (excluding bots):\n");
577+
for contributor in contributors {
578+
output.push_str(&format!("- {contributor}\n"));
579+
}
580+
}
581+
}
582+
583+
output
584+
}
585+
586+
fn is_bot_author(author: &str) -> bool {
587+
let normalized = author.trim().to_ascii_lowercase();
588+
589+
normalized.contains("[bot]")
590+
|| normalized.contains("dependabot")
591+
|| normalized.contains("renovate")
592+
|| normalized.contains("github-actions")
593+
|| normalized.ends_with(" bot")
594+
|| normalized.ends_with("-bot")
595+
|| normalized == "bot"
538596
}
539597

540598
// Git repository info tool

src/git/repository.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,20 @@ impl GitRepo {
700700
commit::get_commits_for_pr(&repo, from, to)
701701
}
702702

703+
/// Get commits between two references with author metadata.
704+
pub fn get_commits_in_range(&self, from: &str, to: &str) -> Result<Vec<RecentCommit>> {
705+
let repo = self.open_repo()?;
706+
let mut commits =
707+
commit::get_commits_between_with_callback(
708+
&repo,
709+
from,
710+
to,
711+
|commit| Ok(commit.clone()),
712+
)?;
713+
commits.reverse();
714+
Ok(commits)
715+
}
716+
703717
/// Get files changed in a commit range
704718
pub fn get_commit_range_files(&self, from: &str, to: &str) -> Result<Vec<StagedFile>> {
705719
let repo = self.open_repo()?;

src/services/git_commit.rs

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ use anyhow::Result;
77
use std::sync::Arc;
88

99
use crate::git::{CommitResult, GitRepo};
10-
use crate::gitmoji::process_commit_message;
1110
use crate::log_debug;
1211

1312
/// Service for performing git commit operations
@@ -23,7 +22,6 @@ use crate::log_debug;
2322
/// - Message generation (handled by agents)
2423
pub struct GitCommitService {
2524
repo: Arc<GitRepo>,
26-
use_gitmoji: bool,
2725
verify: bool,
2826
}
2927

@@ -32,15 +30,12 @@ impl GitCommitService {
3230
///
3331
/// # Arguments
3432
/// * `repo` - The git repository to operate on
35-
/// * `use_gitmoji` - Whether to apply gitmoji to commit messages
33+
/// * `_use_gitmoji` - Retained for API compatibility; commit messages are
34+
/// stored exactly as provided
3635
/// * `verify` - Whether to run pre/post-commit hooks
3736
#[must_use]
38-
pub fn new(repo: Arc<GitRepo>, use_gitmoji: bool, verify: bool) -> Self {
39-
Self {
40-
repo,
41-
use_gitmoji,
42-
verify,
43-
}
37+
pub fn new(repo: Arc<GitRepo>, _use_gitmoji: bool, verify: bool) -> Self {
38+
Self { repo, verify }
4439
}
4540

4641
/// Create from an existing `GitRepo` (convenience constructor)
@@ -82,7 +77,7 @@ impl GitCommitService {
8277
///
8378
/// This method:
8479
/// 1. Validates the repository is not remote
85-
/// 2. Processes the message (applies gitmoji if enabled)
80+
/// 2. Uses the exact message provided
8681
/// 3. Runs pre-commit hook (if verify is enabled)
8782
/// 4. Creates the commit
8883
/// 5. Runs post-commit hook (if verify is enabled)
@@ -101,12 +96,11 @@ impl GitCommitService {
10196
return Err(anyhow::anyhow!("Cannot commit to a remote repository"));
10297
}
10398

104-
let processed_message = process_commit_message(message.to_string(), self.use_gitmoji);
105-
log_debug!("Performing commit with message: {}", processed_message);
99+
log_debug!("Performing commit with message: {}", message);
106100

107101
if !self.verify {
108102
log_debug!("Skipping pre-commit hook (verify=false)");
109-
return self.repo.commit(&processed_message);
103+
return self.repo.commit(message);
110104
}
111105

112106
// Execute pre-commit hook
@@ -118,7 +112,7 @@ impl GitCommitService {
118112
log_debug!("Pre-commit hook executed successfully");
119113

120114
// Perform the commit
121-
match self.repo.commit(&processed_message) {
115+
match self.repo.commit(message) {
122116
Ok(result) => {
123117
// Execute post-commit hook (failure doesn't fail the commit)
124118
log_debug!("Executing post-commit hook");
@@ -139,7 +133,7 @@ impl GitCommitService {
139133
///
140134
/// This method:
141135
/// 1. Validates the repository is not remote
142-
/// 2. Processes the message (applies gitmoji if enabled)
136+
/// 2. Uses the exact message provided
143137
/// 3. Runs pre-commit hook (if verify is enabled)
144138
/// 4. Amends the commit (replaces HEAD)
145139
/// 5. Runs post-commit hook (if verify is enabled)
@@ -160,12 +154,11 @@ impl GitCommitService {
160154
));
161155
}
162156

163-
let processed_message = process_commit_message(message.to_string(), self.use_gitmoji);
164-
log_debug!("Performing amend with message: {}", processed_message);
157+
log_debug!("Performing amend with message: {}", message);
165158

166159
if !self.verify {
167160
log_debug!("Skipping pre-commit hook (verify=false)");
168-
return self.repo.amend_commit(&processed_message);
161+
return self.repo.amend_commit(message);
169162
}
170163

171164
// Execute pre-commit hook
@@ -177,7 +170,7 @@ impl GitCommitService {
177170
log_debug!("Pre-commit hook executed successfully");
178171

179172
// Perform the amend
180-
match self.repo.amend_commit(&processed_message) {
173+
match self.repo.amend_commit(message) {
181174
Ok(result) => {
182175
// Execute post-commit hook (failure doesn't fail the amend)
183176
log_debug!("Executing post-commit hook");

0 commit comments

Comments
 (0)