feat(ai): add AI-assisted commit splitting flow
Introduce a split-planning path for staged changes that asks supported AI providers to group files into ordered Conventional Commit messages. The plan is validated before execution so every staged file is assigned once and unsafe states are rejected. A new dialog lets users review and adjust the proposed groups before creating the commits in sequence, with safeguards to preserve remaining changes if something fails.
This commit is contained in:
@@ -215,6 +215,89 @@ pub async fn review_custom(
|
||||
openai_compatible_review_request(url, api_key, model, diff).await
|
||||
}
|
||||
|
||||
async fn openai_compatible_split_request(
|
||||
url: String,
|
||||
bearer: Option<&str>,
|
||||
model: &str,
|
||||
diff: &str,
|
||||
) -> Result<String, String> {
|
||||
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.";
|
||||
let user =
|
||||
format!("Analyze these staged changes and propose an ordered commit plan:\n\n{diff}");
|
||||
let body = OpenAiRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![
|
||||
OpenAiMessage {
|
||||
role: "system",
|
||||
content: system.to_string(),
|
||||
},
|
||||
OpenAiMessage {
|
||||
role: "user",
|
||||
content: user,
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
};
|
||||
let client = http_client()?;
|
||||
let mut request = client.post(url).json(&body);
|
||||
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
|
||||
request = request.bearer_auth(key);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("API error ({status}): {text}"));
|
||||
}
|
||||
let parsed: OpenAiResponse =
|
||||
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||
parsed
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|choice| choice.message.content)
|
||||
.map(|content| sanitize_message(&content))
|
||||
.filter(|content| !content.is_empty())
|
||||
.ok_or_else(|| "The model did not return a commit plan.".to_string())
|
||||
}
|
||||
|
||||
pub async fn split_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("OpenAI API key is missing.".to_string());
|
||||
}
|
||||
openai_compatible_split_request(
|
||||
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||
Some(api_key),
|
||||
model,
|
||||
diff,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn split_custom(
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
model: &str,
|
||||
diff: &str,
|
||||
) -> Result<String, String> {
|
||||
if base_url.trim().is_empty() {
|
||||
return Err("Endpoint URL is missing.".to_string());
|
||||
}
|
||||
openai_compatible_split_request(
|
||||
format!("{}/chat/completions", base_url.trim_end_matches('/')),
|
||||
api_key,
|
||||
model,
|
||||
diff,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicMessage {
|
||||
role: &'static str,
|
||||
@@ -338,3 +421,47 @@ pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<
|
||||
.filter(|text| !text.is_empty())
|
||||
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||
}
|
||||
|
||||
pub async fn split_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("Anthropic API key is missing.".to_string());
|
||||
}
|
||||
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.".to_string();
|
||||
let body = AnthropicRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: 2400,
|
||||
system,
|
||||
messages: vec![AnthropicMessage {
|
||||
role: "user",
|
||||
content: format!(
|
||||
"Analyze these staged changes and propose an ordered commit plan:\n\n{diff}"
|
||||
),
|
||||
}],
|
||||
};
|
||||
let client = http_client()?;
|
||||
let response = client
|
||||
.post("https://api.anthropic.com/v1/messages")
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("API error ({status}): {text}"));
|
||||
}
|
||||
let parsed: AnthropicResponse =
|
||||
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||
parsed
|
||||
.content
|
||||
.into_iter()
|
||||
.find_map(|block| block.text)
|
||||
.map(|text| sanitize_message(&text))
|
||||
.filter(|text| !text.is_empty())
|
||||
.ok_or_else(|| "The model did not return a commit plan.".to_string())
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ mod cloud;
|
||||
|
||||
pub use cloud::{
|
||||
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
|
||||
review_openai,
|
||||
review_openai, split_anthropic, split_custom, split_openai,
|
||||
};
|
||||
|
||||
use std::{
|
||||
|
||||
@@ -1643,6 +1643,127 @@ pub struct AiReviewResult {
|
||||
pub findings: Vec<AiReviewFinding>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiCommitGroup {
|
||||
pub message: String,
|
||||
pub reason: String,
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiCommitPlan {
|
||||
pub summary: String,
|
||||
pub groups: Vec<AiCommitGroup>,
|
||||
}
|
||||
|
||||
fn parse_ai_commit_plan(raw: &str, staged_files: &[String]) -> Result<AiCommitPlan, String> {
|
||||
use std::collections::HashSet;
|
||||
let trimmed = raw.trim().trim_matches('`').trim();
|
||||
let json = match (trimmed.find('{'), trimmed.rfind('}')) {
|
||||
(Some(start), Some(end)) if start <= end => &trimmed[start..=end],
|
||||
_ => return Err("The AI response did not contain a valid commit plan.".to_string()),
|
||||
};
|
||||
let mut plan: AiCommitPlan = serde_json::from_str(json)
|
||||
.map_err(|error| format!("Could not process the commit plan: {error}"))?;
|
||||
plan.groups
|
||||
.retain(|group| !group.message.trim().is_empty() && !group.files.is_empty());
|
||||
if plan.groups.len() < 2 {
|
||||
return Err("The staged changes do not appear to benefit from splitting.".to_string());
|
||||
}
|
||||
if plan.groups.len() > 12 {
|
||||
return Err("The AI proposed too many commit groups.".to_string());
|
||||
}
|
||||
let expected = staged_files.iter().cloned().collect::<HashSet<_>>();
|
||||
let mut seen = HashSet::new();
|
||||
for group in &mut plan.groups {
|
||||
group.message = group.message.trim().to_string();
|
||||
group.reason = group.reason.trim().to_string();
|
||||
group
|
||||
.files
|
||||
.retain(|file| expected.contains(file) && seen.insert(file.clone()));
|
||||
if group.files.is_empty() {
|
||||
return Err("The AI returned an empty or duplicate commit group.".to_string());
|
||||
}
|
||||
}
|
||||
if seen != expected {
|
||||
return Err("The AI plan did not assign every staged file exactly once.".to_string());
|
||||
}
|
||||
plan.summary = plan.summary.trim().to_string();
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn commit_ai_split(
|
||||
path: String,
|
||||
provider: String,
|
||||
model: Option<String>,
|
||||
api_key: Option<String>,
|
||||
base_url: Option<String>,
|
||||
) -> Result<AiCommitPlan, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let status = status_for_repo(&repo)?;
|
||||
let staged_files = status
|
||||
.files
|
||||
.iter()
|
||||
.filter(|file| file.staged.is_some())
|
||||
.map(|file| file.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if staged_files.len() < 2 {
|
||||
return Err("Stage at least two files before creating a split plan.".to_string());
|
||||
}
|
||||
if status
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.staged.is_some() && file.unstaged.is_some())
|
||||
{
|
||||
return Err("Files with both staged and unstaged changes cannot be split safely. Stage or discard the remaining changes first.".to_string());
|
||||
}
|
||||
let diff = staged_diff(&repo)?;
|
||||
let model = model.filter(|value| !value.trim().is_empty());
|
||||
let api_key = api_key.filter(|value| !value.trim().is_empty());
|
||||
let base_url = base_url.filter(|value| !value.trim().is_empty());
|
||||
let raw = match provider.as_str() {
|
||||
"openai" => {
|
||||
commit_ai::split_openai(
|
||||
api_key
|
||||
.as_deref()
|
||||
.ok_or_else(|| "OpenAI API key is missing.".to_string())?,
|
||||
model.as_deref().unwrap_or("gpt-4o-mini"),
|
||||
&diff,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
"anthropic" => {
|
||||
commit_ai::split_anthropic(
|
||||
api_key
|
||||
.as_deref()
|
||||
.ok_or_else(|| "Anthropic API key is missing.".to_string())?,
|
||||
model.as_deref().unwrap_or("claude-3-5-haiku-latest"),
|
||||
&diff,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
"custom" => {
|
||||
commit_ai::split_custom(
|
||||
base_url
|
||||
.as_deref()
|
||||
.ok_or_else(|| "Endpoint URL is missing.".to_string())?,
|
||||
api_key.as_deref(),
|
||||
model
|
||||
.as_deref()
|
||||
.ok_or_else(|| "Model name is missing.".to_string())?,
|
||||
&diff,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
"local" => return Err("Commit splitting currently requires an API provider.".to_string()),
|
||||
other => return Err(format!("Unknown AI provider: {other}")),
|
||||
};
|
||||
parse_ai_commit_plan(&raw, &staged_files)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AiReviewWireFinding {
|
||||
severity: String,
|
||||
@@ -7105,4 +7226,15 @@ mod tests {
|
||||
assert_eq!(review.findings[0].file.as_deref(), Some("src/main.rs"));
|
||||
assert_eq!(review.findings[0].line, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ai_commit_plan_requires_each_staged_file_exactly_once() {
|
||||
let files = vec!["src/app.ts".to_string(), "tests/app.test.ts".to_string()];
|
||||
let raw = r#"{"summary":"Separate behavior and coverage","groups":[{"message":"feat(app): add behavior","reason":"Production code","files":["src/app.ts"]},{"message":"test(app): cover behavior","reason":"Tests","files":["tests/app.test.ts"]}]}"#;
|
||||
let plan = parse_ai_commit_plan(raw, &files).expect("complete plan should parse");
|
||||
assert_eq!(plan.groups.len(), 2);
|
||||
|
||||
let duplicate = r#"{"summary":"Bad plan","groups":[{"message":"feat: one","reason":"","files":["src/app.ts"]},{"message":"test: two","reason":"","files":["src/app.ts"]}]}"#;
|
||||
assert!(parse_ai_commit_plan(duplicate, &files).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ use git::{
|
||||
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
||||
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
||||
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
|
||||
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete,
|
||||
cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
|
||||
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
|
||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
|
||||
cred_delete, cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
|
||||
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
||||
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
||||
@@ -169,6 +169,7 @@ async fn main() {
|
||||
commit_ai_local_models,
|
||||
commit_ai_generate,
|
||||
commit_ai_review,
|
||||
commit_ai_split,
|
||||
pull,
|
||||
push,
|
||||
fetch,
|
||||
|
||||
Reference in New Issue
Block a user