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 +