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:
Christoph Brandau
2026-07-28 15:01:32 +02:00
parent 3246dfdcfd
commit f0e87d67d5
9 changed files with 471 additions and 5 deletions
+127
View File
@@ -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())
}
+1 -1
View File
@@ -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::{
+132
View File
@@ -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());
}
}
+4 -3
View File
@@ -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,
+86
View File
@@ -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<typeof setInterval> | 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}
<AiCommitSplitDialog
plan={aiCommitPlan}
isApplying={commitAiSplitting}
onApply={applyAiCommitPlan}
onClose={() => { if (!commitAiSplitting) aiCommitSplitOpen = false; }}
/>
{/if}
{#if linePatchOpen && linePatchFile}
<LinePatchDialog
file={linePatchFile}
@@ -0,0 +1,83 @@
<script lang="ts">
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<AiCommitPlan>({ 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];
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isApplying) onClose(); }} />
<div class="dialog-backdrop" role="presentation">
<div class="dialog split-dialog" role="dialog" aria-modal="true" aria-label="AI commit split">
<header class="dialog-header">
<div><span class="eyebrow">Staged changes</span><h2>Split into logical commits</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isApplying} aria-label="Close"><X size={18} /></button>
</header>
<div class="split-intro"><Sparkles size={18} /><p>{draft.summary}</p></div>
<div class="split-groups">
{#each draft.groups as group, groupIndex}
<article class:empty={group.files.length === 0}>
<header><GitCommitHorizontal size={17} /><strong>Commit {groupIndex + 1}</strong><span>{group.files.length} files</span></header>
<label>Commit message<input value={group.message} oninput={(event) => setMessage(groupIndex, event.currentTarget.value)} disabled={isApplying} /></label>
{#if group.reason}<p>{group.reason}</p>{/if}
<div class="split-files">
{#each group.files as file}
<div><code>{file}</code>
<select value={groupIndex} onchange={(event) => moveFile(file, groupIndex, Number(event.currentTarget.value))} disabled={isApplying}>
{#each draft.groups as _, target}<option value={target}>Commit {target + 1}</option>{/each}
</select>
</div>
{/each}
</div>
</article>
{/each}
</div>
<footer class="dialog-footer">
<p>Files with staged and unstaged edits are excluded for safety. Commits are created in this order.</p>
<div><button class="btn-secondary" type="button" onclick={onClose} disabled={isApplying}>Cancel</button>
<button class="btn-primary" type="button" onclick={() => onApply(structuredClone(draft))} disabled={!valid || isApplying}>
{#if isApplying}<LoaderCircle class="spin" size={14} />{/if}Create {draft.groups.length} commits
</button></div>
</footer>
</div>
</div>
<style>
.split-dialog{width:min(820px,calc(100vw - 32px));max-height:min(820px,calc(100vh - 32px));display:flex;flex-direction:column}
.split-intro{display:flex;gap:10px;align-items:flex-start;padding:14px 18px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
.split-intro p{margin:0;line-height:1.5}
.split-groups{display:grid;gap:10px;padding:14px 18px;overflow:auto}
article{display:grid;gap:10px;padding:13px;border:1px solid var(--color-border-subtle);border-radius:9px;background:var(--color-surface-raised)}
article.empty{border-color:#d88a45}
article>header{display:flex;align-items:center;gap:8px;color:var(--color-ink)}
article>header span{margin-left:auto;color:var(--color-ink-faint);font-size:11px}
label{display:grid;gap:5px;color:var(--color-ink-faint);font-size:10px;font-weight:800;text-transform:uppercase}
input{height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-ink);font-family:var(--font-mono)}
article>p{margin:0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
.split-files{display:grid;gap:5px}
.split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)}
code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap}
select{height:28px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface-raised);color:var(--color-ink);font-size:11px}
</style>
+16 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
@@ -14,12 +14,14 @@
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
commitAiReviewing: boolean;
commitAiSplitting: boolean;
canAmend: boolean;
amendMode: boolean;
onCommit: () => void;
onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onReviewStaged: () => void;
onSplitStaged: () => void;
onOpenAiSettings: () => void;
onToggleAmend: (checked: boolean) => void;
onUndoLastCommit: () => void;
@@ -37,12 +39,14 @@
commitAiPhase = "idle",
commitAiGenerating = false,
commitAiReviewing = false,
commitAiSplitting = false,
canAmend = false,
amendMode = false,
onCommit = () => {},
onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onReviewStaged = () => {},
onSplitStaged = () => {},
onOpenAiSettings = () => {},
onToggleAmend = () => {},
onUndoLastCommit = () => {},
@@ -70,6 +74,7 @@
(commitAiProvider !== "local" || commitAiPhase === "ready"),
);
let canReview = $derived(canGenerate && commitAiProvider !== "local");
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
</script>
<section class="panel commit-panel" aria-label="Commit">
@@ -80,6 +85,16 @@
</div>
<div class="commit-head-actions">
<span class="pill pill-count">{stagedCount} staged</span>
<button
class="commit-review-button"
type="button"
onclick={onSplitStaged}
disabled={!canSplit}
title={commitAiProvider === "local" ? "Commit splitting currently requires an API provider" : "Suggest logical commits for the staged files"}
>
{#if commitAiSplitting}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<GitCommitHorizontal size={14} aria-hidden="true" />{/if}
Split
</button>
<button
class="commit-review-button"
type="button"
+11
View File
@@ -2,6 +2,7 @@ import { tracedInvoke as invoke } from "./telemetry";
import type {
AiReviewResult,
AiCommitPlan,
CommitAiLocalProfile,
CommitAiProvider,
CommitAiStatus,
@@ -322,6 +323,16 @@ export function commitAiReview(path: string, options: CommitAiGenerateOptions):
});
}
export function commitAiSplit(path: string, options: CommitAiGenerateOptions): Promise<AiCommitPlan> {
return invoke<AiCommitPlan>("commit_ai_split", {
path,
provider: options.provider,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
});
}
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
}
+11
View File
@@ -37,6 +37,17 @@ export interface AiReviewResult {
findings: AiReviewFinding[];
}
export interface AiCommitGroup {
message: string;
reason: string;
files: string[];
}
export interface AiCommitPlan {
summary: string;
groups: AiCommitGroup[];
}
export interface LocalModelOption {
id: string;
label: string;