try to use local ai to generate commit message

This commit is contained in:
Christoph Brandau
2026-07-02 19:59:16 +02:00
parent 2a96e79d27
commit f2aa48d2ec
13 changed files with 3889 additions and 62 deletions
+15 -1
View File
@@ -34,7 +34,21 @@
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)", "Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
"Bash(sudo apt install -y libdbus-1-dev pkg-config)", "Bash(sudo apt install -y libdbus-1-dev pkg-config)",
"Bash(dpkg -l)", "Bash(dpkg -l)",
"Bash(apt list *)" "Bash(apt list *)",
"Bash(cargo search *)",
"Bash(curl -s \"https://crates.io/api/v1/crates/mistralrs\")",
"Bash(cargo info *)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(gh api *)",
"WebFetch(domain:github.com)",
"WebFetch(domain:ericlbuehler.github.io)",
"WebFetch(domain:docs.rs)",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF\")",
"Bash(python3 -c ' *)",
"Bash(curl -sI \"https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF?blobs=true\")",
"Bash(grep -n 'from \"\\\\./lib/git\"\\\\|from \"\\\\./lib/types\"\\\\|^ commit,$' src/App.svelte)",
"Bash(kill 24343 24363 24375 24376)"
] ]
} }
} }
+1
View File
@@ -6,3 +6,4 @@
.DS_Store .DS_Store
~ ~
.codex* .codex*
target
+3528 -41
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -2,16 +2,23 @@
name = "git_lite" name = "git_lite"
version = "0.1.0" version = "0.1.0"
description = "Rust backend for a lightweight Git desktop client" description = "Rust backend for a lightweight Git desktop client"
edition = "2021" edition = "2024"
rust-version = "1.77" rust-version = "1.88"
build = "build.rs" build = "build.rs"
[workspace]
members = [".", "crates/commit_ai"]
[workspace.package]
edition = "2024"
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "=2.7.0" tauri-plugin-dialog = "=2.7.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] } keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" }
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "commit_ai"
version = "0.1.0"
edition = "2024"
[dependencies]
mistralrs = "0.8"
tokio = { version = "1", features = ["sync"] }
serde = { version = "1", features = ["derive"] }
+157
View File
@@ -0,0 +1,157 @@
use std::sync::Arc;
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages};
use tokio::sync::RwLock;
// Small instruct model, quantized to keep the one-time download reasonable (~1 GB) while
// still being fast enough for short, structured generations like a commit message on CPU.
const HF_REPO: &str = "Qwen/Qwen2.5-1.5B-Instruct-GGUF";
const GGUF_FILE: &str = "qwen2.5-1.5b-instruct-q4_k_m.gguf";
const TOKENIZER_MODEL_ID: &str = "Qwen/Qwen2.5-1.5B-Instruct";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CommitAiPhase {
/// Nothing has been requested yet.
Idle,
/// Downloading (first run only, then cached by hf-hub) and/or loading into memory.
Loading,
Ready,
Error,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CommitAiStatus {
pub phase: CommitAiPhase,
pub error: Option<String>,
}
struct Inner {
phase: CommitAiPhase,
error: Option<String>,
model: Option<Arc<Model>>,
}
/// Cheap to clone: shares one model instance across the app via an inner `Arc`.
#[derive(Clone)]
pub struct CommitAiEngine {
inner: Arc<RwLock<Inner>>,
}
impl Default for CommitAiEngine {
fn default() -> Self {
Self {
inner: Arc::new(RwLock::new(Inner {
phase: CommitAiPhase::Idle,
error: None,
model: None,
})),
}
}
}
impl CommitAiEngine {
pub fn new() -> Self {
Self::default()
}
pub async fn status(&self) -> CommitAiStatus {
let guard = self.inner.read().await;
CommitAiStatus {
phase: guard.phase,
error: guard.error.clone(),
}
}
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the model.
/// Safe to call multiple times — only the first caller actually triggers a load, later
/// callers just return once the in-flight or previous attempt is done.
pub async fn ensure_loaded(&self) {
{
let mut guard = self.inner.write().await;
if guard.phase != CommitAiPhase::Idle {
return;
}
guard.phase = CommitAiPhase::Loading;
guard.error = None;
}
let result = GgufModelBuilder::new(HF_REPO, vec![GGUF_FILE])
.with_tok_model_id(TOKENIZER_MODEL_ID)
.with_logging()
.build()
.await;
let mut guard = self.inner.write().await;
match result {
Ok(model) => {
guard.model = Some(Arc::new(model));
guard.phase = CommitAiPhase::Ready;
guard.error = None;
}
Err(err) => {
guard.phase = CommitAiPhase::Error;
guard.error = Some(err.to_string());
}
}
}
pub async fn generate_commit_message(
&self,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
let model = {
let guard = self.inner.read().await;
match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => model.clone(),
_ => return Err("Das KI-Modell ist noch nicht bereit.".to_string()),
}
};
if diff.trim().is_empty() {
return Err("Keine gestagten Änderungen für eine Commit-Message vorhanden.".to_string());
}
let (system, user) = build_messages(diff, notes);
let messages = TextMessages::new()
.add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user);
let response = model
.send_chat_request(messages)
.await
.map_err(|err| err.to_string())?;
let content = response
.choices
.first()
.and_then(|choice| choice.message.content.clone())
.ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string())?;
Ok(content.trim().to_string())
}
}
fn build_messages(diff: &str, notes: Option<&str>) -> (String, String) {
// grobe Token-Schätzung, kleine Modelle haben oft 832k Kontext
const MAX_CHARS: usize = 24_000;
let diff = if diff.len() > MAX_CHARS {
format!("{}\n\n[... Diff gekürzt ...]", &diff[..MAX_CHARS])
} else {
diff.to_string()
};
let system = "Du bist ein Werkzeug, das Git-Commit-Messages erzeugt. \
Antworte ausschließlich mit der Commit-Message im Conventional-Commits-Format \
(<type>(<scope>): <subject>), optional gefolgt von einem Body nach einer Leerzeile. \
Subject imperativ, max. 72 Zeichen. Kein Vorspann, keine Erklärung, keine Code-Fences, in Englisch antworten"
.to_string();
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Anmerkungen des Entwicklers:\n{n}\n\n"));
}
user.push_str(&format!("Staged diff:\n{diff}"));
(system, user)
}
+32
View File
@@ -479,6 +479,37 @@ pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String
Ok(String::from_utf8_lossy(&output).to_string()) Ok(String::from_utf8_lossy(&output).to_string())
} }
#[tauri::command]
pub async fn commit_ai_status(
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
) -> Result<commit_ai::CommitAiStatus, String> {
Ok(engine.status().await)
}
#[tauri::command]
pub async fn commit_ai_generate(
path: String,
notes: Option<String>,
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
) -> Result<String, String> {
let repo = resolve_repo(&path)?;
let diff = run_git(
&repo,
[
"diff",
"--cached",
"--no-ext-diff",
"--no-textconv",
"--unified=3",
],
)?;
let diff = String::from_utf8_lossy(&diff).to_string();
engine
.generate_commit_message(&diff, notes.as_deref())
.await
}
#[tauri::command] #[tauri::command]
pub fn apply_file_patch( pub fn apply_file_patch(
path: String, path: String,
@@ -1573,6 +1604,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
let (branch, mut files) = parse_status_output(&output)?; let (branch, mut files) = parse_status_output(&output)?;
detect_worktree_renames(repo, &mut files); detect_worktree_renames(repo, &mut files);
Ok(GitStatus { Ok(GitStatus {
repo_path: repo.to_string_lossy().to_string(), repo_path: repo.to_string_lossy().to_string(),
current_branch: branch.current_branch, current_branch: branch.current_branch,
+21 -7
View File
@@ -4,21 +4,33 @@ mod git;
use git::{ use git::{
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit, apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, commit_ai_generate, commit_ai_status, compare_commits, compare_file_to_head,
cred_load, cred_save, delete_branch, diff_file_against_working_tree, get_file_patch, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
get_remote_url, get_status, list_branches, list_commits, list_file_history, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
list_repository_files, merge_branch, open_repo_in_explorer, open_repository, list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer,
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch, open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_to_commit, search_code_introductions, stage_files, unstage_files, restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
SearchCancellationState, SearchCancellationState,
}; };
fn main() { fn main() {
let commit_ai_engine = commit_ai::CommitAiEngine::new();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.manage(SearchCancellationState::default()) .manage(SearchCancellationState::default())
.manage(commit_ai_engine.clone())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.setup(move |_app| {
// Kick off the (first-run-only) download and model load in the background so the
// "Generate with AI" button becomes enabled once it's ready, without blocking startup.
let engine = commit_ai_engine.clone();
tauri::async_runtime::spawn(async move {
engine.ensure_loaded().await;
});
Ok(())
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
open_repository, open_repository,
open_repo_in_explorer, open_repo_in_explorer,
@@ -35,6 +47,8 @@ fn main() {
get_file_patch, get_file_patch,
apply_file_patch, apply_file_patch,
commit, commit,
commit_ai_status,
commit_ai_generate,
pull, pull,
push, push,
list_commits, list_commits,
+54 -1
View File
@@ -25,6 +25,8 @@
import { import {
checkoutBranch, checkoutBranch,
commit, commit,
commitAiGenerate,
commitAiStatus,
compareCommits, compareCommits,
cancelCodeSearch, cancelCodeSearch,
cancelFileHistory, cancelFileHistory,
@@ -62,6 +64,7 @@
} from "./lib/git"; } from "./lib/git";
import type { import type {
CommitAiPhase,
ConflictFile, ConflictFile,
ExplorerNode, ExplorerNode,
ExplorerNodeKind, ExplorerNodeKind,
@@ -122,7 +125,11 @@
let fileHistoryLoading = false; let fileHistoryLoading = false;
let fileHistoryRequestId = 0; let fileHistoryRequestId = 0;
let activeFileHistoryRequestId = ""; let activeFileHistoryRequestId = "";
let lastFileHistoryHeadHash = "";
let commitMessage = ""; let commitMessage = "";
let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false;
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
let errorMessage = ""; let errorMessage = "";
let operation = ""; let operation = "";
let compareFrom = ""; let compareFrom = "";
@@ -206,10 +213,12 @@
loadRepoLists(); loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
void checkForUpdates(); void checkForUpdates();
startCommitAiPolling();
}); });
onDestroy(() => { onDestroy(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer); if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId); if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
}); });
@@ -229,15 +238,54 @@
applyStatus(nextStatus); applyStatus(nextStatus);
// Something changed — reload branches, commits and files in one bundled call. // Something changed — reload branches, commits and files in one bundled call.
const bundle = await openRepositoryBundle(activeRepoPath, 100); const bundle = await openRepositoryBundle(activeRepoPath, 100);
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(activeRepoPath, bundle.branches);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); await refreshExplorerFiles(activeRepoPath, bundle.files);
await refreshFileHistory(activeRepoPath); // File history reflects `git log`, which only changes when HEAD actually moves
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
// working-tree/status change (staging, edits) doesn't keep re-fetching and
// flickering the currently viewed file's history.
if (lastFileHistoryHeadHash !== previousHeadHash) {
await refreshFileHistory(activeRepoPath);
}
} catch { /* ignore transient errors */ } finally { } catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false; autoRefreshInFlight = false;
} }
} }
// ── Commit AI ──────────────────────────────────────────────────────────────
async function pollCommitAiStatus() {
try {
const result = await commitAiStatus();
commitAiPhase = result.phase;
} catch { /* ignore transient errors */ }
if (commitAiPhase === "ready" || commitAiPhase === "error") {
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
}
}
function startCommitAiPolling() {
// The model downloads (first run only) and loads in the background on app start;
// poll until it's ready (or failed) so the "AI" button can enable itself.
void pollCommitAiStatus();
commitAiPollTimer = setInterval(() => { void pollCommitAiStatus(); }, 2000);
}
async function generateCommitMessageWithAi() {
if (!activeRepoPath || commitAiPhase !== "ready" || commitAiGenerating) return;
commitAiGenerating = true;
errorMessage = "";
try {
commitMessage = await commitAiGenerate(activeRepoPath, commitMessage.trim() || undefined);
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
commitAiGenerating = false;
}
}
function toggleAutoRefresh() { function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled; autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) void autoRefreshTick(); if (autoRefreshEnabled) void autoRefreshTick();
@@ -430,6 +478,7 @@
} }
branches = []; branches = [];
commits = []; commits = [];
lastFileHistoryHeadHash = "";
repoFiles = []; repoFiles = [];
selectedExplorerPath = ""; selectedExplorerPath = "";
selectedExplorerKind = "file"; selectedExplorerKind = "file";
@@ -520,6 +569,7 @@
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) { async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100)); commits = prefetched ?? (await listCommits(path, 100));
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
const hashes = new Set(commits.map((c) => c.hash)); const hashes = new Set(commits.map((c) => c.hash));
if (compareFrom && !hashes.has(compareFrom)) compareFrom = ""; if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
if (compareTo && !hashes.has(compareTo)) compareTo = ""; if (compareTo && !hashes.has(compareTo)) compareTo = "";
@@ -1692,8 +1742,11 @@
{isBusy} {isBusy}
{operation} {operation}
{stagedCount} {stagedCount}
{commitAiPhase}
{commitAiGenerating}
onCommit={commitChanges} onCommit={commitChanges}
onCommitMessageChange={(msg) => { commitMessage = msg; }} onCommitMessageChange={(msg) => { commitMessage = msg; }}
onGenerateCommitMessage={generateCommitMessageWithAi}
/> />
</div> </div>
</section> </section>
+2
View File
@@ -1332,6 +1332,8 @@
.commit-form { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; gap: 8px; padding: 10px; } .commit-form { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; gap: 8px; padding: 10px; }
.commit-form textarea { flex: 1 1 0; min-height: 80px; resize: none; } .commit-form textarea { flex: 1 1 0; min-height: 80px; resize: none; }
.commit-actions-row { display: flex; gap: 8px; }
.commit-ai-button { flex: 0 0 auto; min-width: 64px; justify-content: center; }
.commit-block-reason { .commit-block-reason {
margin: 0; margin: 0;
padding: 8px 10px; padding: 8px 10px;
+43 -9
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Check, LoaderCircle } from "@lucide/svelte"; import { Check, LoaderCircle, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase } from "../types";
interface Props { interface Props {
commitMessage: string; commitMessage: string;
@@ -9,8 +10,11 @@
isBusy: boolean; isBusy: boolean;
operation: string; operation: string;
stagedCount: number; stagedCount: number;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
onCommit: () => void; onCommit: () => void;
onCommitMessageChange: (msg: string) => void; onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
} }
let { let {
@@ -21,14 +25,28 @@
isBusy = false, isBusy = false,
operation = "", operation = "",
stagedCount = 0, stagedCount = 0,
commitAiPhase = "idle",
commitAiGenerating = false,
onCommit = () => {}, onCommit = () => {},
onCommitMessageChange = () => {}, onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
}: Props = $props(); }: Props = $props();
function handleSubmit(event: SubmitEvent) { function handleSubmit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
onCommit(); onCommit();
} }
function aiButtonTitle(phase: CommitAiPhase, staged: number): string {
if (phase === "loading") return "AI model is downloading/loading — this happens once";
if (phase === "error") return "AI model failed to load";
if (staged === 0) return "Stage changes first";
return "Generate commit message with AI from the staged diff";
}
let canGenerate = $derived(
hasRepository && !isBusy && !commitAiGenerating && commitAiPhase === "ready" && stagedCount > 0,
);
</script> </script>
<section class="panel flex flex-col" aria-label="Commit"> <section class="panel flex flex-col" aria-label="Commit">
@@ -50,13 +68,29 @@
{#if commitBlockReason} {#if commitBlockReason}
<p class="commit-block-reason">{commitBlockReason}</p> <p class="commit-block-reason">{commitBlockReason}</p>
{/if} {/if}
<button class="btn-primary w-full flex-shrink-0" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}> <div class="commit-actions-row flex-shrink-0">
{#if operation === "Committing"} <button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
<LoaderCircle class="spin" size={16} aria-hidden="true" /> {#if operation === "Committing"}
{:else} <LoaderCircle class="spin" size={16} aria-hidden="true" />
<Check size={16} aria-hidden="true" /> {:else}
{/if} <Check size={16} aria-hidden="true" />
Commit {/if}
</button> Commit
</button>
<button
class="btn-secondary commit-ai-button"
type="button"
onclick={onGenerateCommitMessage}
disabled={!canGenerate}
title={aiButtonTitle(commitAiPhase, stagedCount)}
>
{#if commitAiGenerating || commitAiPhase === "loading"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Sparkles size={16} aria-hidden="true" />
{/if}
AI
</button>
</div>
</form> </form>
</section> </section>
+9
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { import type {
CommitAiStatus,
ConflictFile, ConflictFile,
GitBranch, GitBranch,
GitCommit, GitCommit,
@@ -94,6 +95,14 @@ export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message }); return invoke<GitStatus>("commit", { path, message });
} }
export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status");
}
export function commitAiGenerate(path: string, notes?: string): Promise<string> {
return invoke<string>("commit_ai_generate", { path, notes });
}
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> { export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null }); return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
} }
+7
View File
@@ -7,6 +7,13 @@ export type FileStatusKind =
| "conflicted" | "conflicted"
| "unknown"; | "unknown";
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export interface CommitAiStatus {
phase: CommitAiPhase;
error: string | null;
}
export interface GitStatus { export interface GitStatus {
repo_path: string; repo_path: string;
current_branch: string | null; current_branch: string | null;