Files
GitLite/src-tauri/crates/commit_ai/src/lib.rs
T
Christoph 4db6f30461 refactor(commit-ai): remove local models and simplify AI flow
Remove local on-device model support and related IPC commands,
consolidating commit-generation to cloud providers and simplifying the
AI crate surface. Local-specific types, generation profiles, caching,
and the local prompt builder were removed while message sanitization and
diff-echo detection were preserved. Also harden repository handling and
runtime: unborn HEADs are handled gracefully so empty repos still report
files, Git LFS sync is skipped for repositories without commits, and
tokio runtime features were enabled.

- Remove local model engine, load/status commands, and local profile code
- Handle unborn HEAD and skip LFS sync for repos without commits
- Enable tokio runtime features and route AI generation to cloud only
2026-08-30 19:16:32 +02:00

120 lines
5.4 KiB
Rust

mod cloud;
pub use cloud::{
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
review_openai, split_anthropic, split_custom, split_openai,
};
/// Strip a wrapping code fence and wrapping quotes so the result can go straight into
/// the commit-message box.
pub(crate) fn sanitize_message(raw: &str) -> String {
let mut text = raw.trim().to_string();
if text.starts_with("```") {
text = match text.split_once('\n') {
// Drop the opening fence line (which may carry a language tag) and the closing fence.
Some((_fence, rest)) => rest.trim_end().trim_end_matches("```").trim().to_string(),
None => text.trim_matches('`').trim().to_string(),
};
}
let trimmed = text.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
return trimmed[1..trimmed.len() - 1].trim().to_string();
}
trimmed.to_string()
}
/// Some models echo the prompt's diff sections instead of writing a commit message.
/// Catch that so the UI can show a clear error instead of dumping raw diff text into
/// the commit-message box.
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("diff --git")
|| lower.contains("staged files:")
|| lower.contains("staged changes:")
|| lower.contains("diff stat:")
|| lower.contains("detailed diff:")
|| message.lines().any(|line| line.starts_with("@@ "))
}
fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
if input.len() <= max_chars {
return input.to_string();
}
let mut cut = max_chars;
while !input.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... diff truncated ...]", &input[..cut])
}
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
// Rough token estimate to keep requests within common context windows.
const MAX_CHARS: usize = 24_000;
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
let system = "You are a tool that writes a Git commit message describing a staged diff. \
Output ONLY the commit message text. Never quote, restate, or paraphrase the diff itself: \
do not include lines starting with 'diff --git', '@@', '+', '-', 'index ', 'Staged files:', \
or 'Diff stat:' anywhere in your answer. \
Format: a Conventional Commits header (<type>(<scope>): <subject>) in imperative mood, \
max. 72 characters, then a blank line, then a body. \
The body is required: a short, general paragraph (2-4 sentences) summarizing what changed \
and why at a high level — do NOT enumerate every changed file individually. \
You may optionally add up to 3 bullet points (- ...) afterward, but only for the most \
significant changes overall, never one bullet or heading per file. \
Never use bold text, backticks, or markdown headings for file names. \
Lines in the body max. 72 characters. \
No preamble, no explanation, no code fences, answer in English.\n\n\
Example:\n\
Diff:\n\
diff --git a/src/auth.py b/src/auth.py\n\
+def hash_password(pw):\n\
+ return bcrypt.hash(pw)\n\
diff --git a/src/routes.py b/src/routes.py\n\
-if password == stored_password:\n\
+if bcrypt.check(password, stored_password):\n\n\
Commit message:\n\
feat(auth): hash and verify passwords with bcrypt\n\n\
Passwords were previously compared as plain text. This adds a bcrypt-based\n\
hashing helper and updates the login check to verify against the hash\n\
instead of a direct string comparison.\n\n\
- Hash passwords on write, verify with bcrypt on login"
.to_string();
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Developer notes:\n{n}\n\n"));
}
user.push_str(&format!("Staged diff:\n{diff}"));
Ok((system, user))
}
pub(crate) fn build_review_messages(diff: &str) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for review.".to_string());
}
const MAX_CHARS: usize = 36_000;
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
let system = r#"You are a senior software engineer performing a focused pre-commit review.
Review only the supplied staged Git diff. Look for concrete correctness bugs, security issues,
data loss, regressions, broken edge cases, unsafe error handling, and meaningful performance or
maintainability risks. Do not report formatting preferences or speculative nitpicks.
Return ONLY valid JSON with this exact shape:
{"summary":"one concise overall assessment","risk":"low|medium|high","findings":[{"severity":"critical|warning|info","title":"short title","description":"clear evidence and impact","file":"path or null","line":123,"suggestion":"specific safe next step"}]}
Use the new-file line number from the diff when it is known; otherwise use null. Use null for file
when the issue is repository-wide. Maximum 12 findings, ordered critical then warning then info.
If no actionable issue exists, return an empty findings array and risk low. Never use markdown,
code fences, commentary outside the JSON, or claim that tests were executed."#
.to_string();
let user = format!("Staged diff to review:\n{diff}");
Ok((system, user))
}