From f0e87d67d5886ff709691846f2eb6e3363a4ef73 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 28 Jul 2026 15:01:32 +0200 Subject: [PATCH] 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. --- src-tauri/crates/commit_ai/src/cloud.rs | 127 +++++++++++++++++ src-tauri/crates/commit_ai/src/lib.rs | 2 +- src-tauri/src/git.rs | 132 ++++++++++++++++++ src-tauri/src/main.rs | 7 +- src/App.svelte | 86 ++++++++++++ src/lib/components/AiCommitSplitDialog.svelte | 83 +++++++++++ src/lib/components/CommitPanel.svelte | 17 ++- src/lib/git.ts | 11 ++ src/lib/types.ts | 11 ++ 9 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/AiCommitSplitDialog.svelte diff --git a/src-tauri/crates/commit_ai/src/cloud.rs b/src-tauri/crates/commit_ai/src/cloud.rs index fa5d53e..6ad64cd 100644 --- a/src-tauri/crates/commit_ai/src/cloud.rs +++ b/src-tauri/crates/commit_ai/src/cloud.rs @@ -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 { + 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 { + 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 { + 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 { + 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()) +} diff --git a/src-tauri/crates/commit_ai/src/lib.rs b/src-tauri/crates/commit_ai/src/lib.rs index 915b694..2c80883 100644 --- a/src-tauri/crates/commit_ai/src/lib.rs +++ b/src-tauri/crates/commit_ai/src/lib.rs @@ -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::{ diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 7ad41ce..4bfb95e 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1643,6 +1643,127 @@ pub struct AiReviewResult { pub findings: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiCommitGroup { + pub message: String, + pub reason: String, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiCommitPlan { + pub summary: String, + pub groups: Vec, +} + +fn parse_ai_commit_plan(raw: &str, staged_files: &[String]) -> Result { + 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::>(); + 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, + api_key: Option, + base_url: Option, +) -> Result { + 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::>(); + 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()); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8a5764e..c52ce6b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src/App.svelte b/src/App.svelte index 6526f1c..c615e64 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -10,6 +10,7 @@ import RepoTabs from "./lib/RepoTabs.svelte"; import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; import AiReviewDialog from "./lib/components/AiReviewDialog.svelte"; + import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte"; import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte"; import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; @@ -51,6 +52,7 @@ commit, commitAiGenerate, commitAiReview, + commitAiSplit, commitAiLoad, commitAiLocalModels, commitAiStatus, @@ -129,6 +131,7 @@ import type { AiReviewResult, + AiCommitPlan, AiSettings, AppLanguage, AppTheme, @@ -293,6 +296,9 @@ let commitAiPhase: CommitAiPhase = "idle"; let commitAiGenerating = false; let commitAiReviewing = false; + let commitAiSplitting = false; + let aiCommitPlan: AiCommitPlan | null = null; + let aiCommitSplitOpen = false; let aiReviewResult: AiReviewResult | null = null; let aiReviewOpen = false; let commitAiPollTimer: ReturnType | undefined; @@ -1034,6 +1040,75 @@ } } + async function splitStagedWithAi() { + if (!activeRepoPath || commitAiSplitting || stagedCount < 2) return; + if (aiSettings.provider === "local") { + errorMessage = "Commit splitting currently requires OpenAI, Anthropic, or a custom endpoint."; + return; + } + commitAiSplitting = true; + errorMessage = ""; + try { + if (aiSettings.provider === "openai") { + const cred = await credLoad("ai:openai"); + aiCommitPlan = await commitAiSplit(activeRepoPath, { provider: "openai", model: aiSettings.openaiModel, apiKey: cred?.password }); + } else if (aiSettings.provider === "anthropic") { + const cred = await credLoad("ai:anthropic"); + aiCommitPlan = await commitAiSplit(activeRepoPath, { provider: "anthropic", model: aiSettings.anthropicModel, apiKey: cred?.password }); + } else { + const cred = await credLoad("ai:custom"); + aiCommitPlan = await commitAiSplit(activeRepoPath, { + provider: "custom", model: aiSettings.customModel, baseUrl: aiSettings.customBaseUrl, apiKey: cred?.password, + }); + } + aiCommitSplitOpen = true; + trackEvent("ai_commit_split_planned", { provider: aiSettings.provider, groups: aiCommitPlan.groups.length }); + } catch (error) { + errorMessage = errorToMessage(error); + } finally { + commitAiSplitting = false; + } + } + + async function applyAiCommitPlan(plan: AiCommitPlan) { + if (!activeRepoPath || isBusy || commitAiSplitting) return; + const allFiles = plan.groups.flatMap((group) => group.files); + if (plan.groups.length < 2 || plan.groups.some((group) => !group.message.trim() || group.files.length === 0)) return; + const currentStaged = changedFiles.filter((file) => file.staged !== null).map((file) => file.path).sort(); + if (currentStaged.join("\n") !== [...allFiles].sort().join("\n")) { + errorMessage = "The staged files changed after the plan was created. Generate a new split plan."; + return; + } + if (changedFiles.some((file) => file.staged !== null && file.unstaged !== null)) { + errorMessage = "A file now has both staged and unstaged changes. Stage or discard the remaining changes first."; + return; + } + + commitAiSplitting = true; + await runOperation("Creating split commits", async () => { + applyStatus(await unstageFiles(activeRepoPath, currentStaged)); + let completed = 0; + try { + for (const group of plan.groups) { + applyStatus(await stageFiles(activeRepoPath, group.files)); + applyStatus(await commit(activeRepoPath, group.message.trim())); + completed += 1; + } + } catch (error) { + throw new Error(`${completed} of ${plan.groups.length} commits were created. Remaining changes are preserved and can be staged again. ${errorToMessage(error)}`); + } + aiCommitSplitOpen = false; + aiCommitPlan = null; + commitMessage = ""; + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + trackEvent("ai_commit_split_applied", { groups: plan.groups.length, files: allFiles.length }); + }); + commitAiSplitting = false; + } + // ── Updates ──────────────────────────────────────────────────────────────── async function checkForUpdates() { @@ -4440,12 +4515,14 @@ {commitAiPhase} {commitAiGenerating} {commitAiReviewing} + {commitAiSplitting} {canAmend} {amendMode} onCommit={commitChanges} onCommitMessageChange={updateCommitMessage} onGenerateCommitMessage={generateCommitMessageWithAi} onReviewStaged={reviewStagedWithAi} + onSplitStaged={splitStagedWithAi} onOpenAiSettings={() => { aiSettingsOpen = true; }} onToggleAmend={toggleAmendMode} onUndoLastCommit={undoLastCommitChange} @@ -4588,6 +4665,15 @@ /> {/if} +{#if aiCommitSplitOpen && aiCommitPlan} + { if (!commitAiSplitting) aiCommitSplitOpen = false; }} + /> +{/if} + {#if linePatchOpen && linePatchFile} + import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte"; + import type { AiCommitPlan } from "../types"; + + interface Props { + plan: AiCommitPlan; + isApplying: boolean; + onApply: (plan: AiCommitPlan) => void; + onClose: () => void; + } + + let { plan, isApplying = false, onApply, onClose }: Props = $props(); + let draft = $state({ summary: "", groups: [] }); + $effect.pre(() => { + if (draft.groups.length === 0) draft = structuredClone(plan); + }); + let valid = $derived(draft.groups.length > 1 && draft.groups.every((group) => group.message.trim() && group.files.length)); + + function setMessage(index: number, message: string) { + draft.groups[index].message = message; + } + + function moveFile(file: string, from: number, to: number) { + if (from === to) return; + draft.groups[from].files = draft.groups[from].files.filter((path) => path !== file); + draft.groups[to].files = [...draft.groups[to].files, file]; + } + + + { if (event.key === "Escape" && !isApplying) onClose(); }} /> + + + + diff --git a/src/lib/components/CommitPanel.svelte b/src/lib/components/CommitPanel.svelte index c73efaf..37ec44b 100644 --- a/src/lib/components/CommitPanel.svelte +++ b/src/lib/components/CommitPanel.svelte @@ -1,5 +1,5 @@
@@ -80,6 +85,16 @@
{stagedCount} staged +