feat(core): Improve stability, logging, and LLM context handling
This commit is contained in:
12
src-tauri/Cargo.lock
generated
12
src-tauri/Cargo.lock
generated
@@ -273,7 +273,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "auto-git"
|
||||
version = "1.0.0"
|
||||
version = "1.0.8"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"auto-launch",
|
||||
@@ -291,6 +291,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4798,6 +4799,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "auto-git"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
description = "Auto-Git: Git monitoring with automatic LLM commit messages and README generation"
|
||||
authors = ["Victor Giers"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -45,6 +45,9 @@ const MAX_SQUASH_COMMIT_MESSAGE_CHARS: usize = 160;
|
||||
const OLLAMA_BASE_URL: &str = "http://127.0.0.1:11434";
|
||||
const ROSE_TITLEBAR_COLOR: Color = Color(255, 241, 242, 255);
|
||||
const GIT_PROCESS_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
const MAX_COMMIT_MESSAGE_PROMPT_CHARS: usize = 32_000;
|
||||
const MAX_COMMIT_DIFF_TOTAL_CHARS: usize = 18_000;
|
||||
const OLLAMA_COMMIT_CONTEXT_TOKENS: u64 = 16_384;
|
||||
// Automatic rewriting is safe to enable because all history mutation happens in an isolated
|
||||
// worktree and the real branch is updated only after tree validation and a compare-and-swap.
|
||||
const AUTO_HISTORY_REWRITE_ENABLED: bool = true;
|
||||
@@ -150,7 +153,6 @@ const IGNORED_NAMES: &[&str] = &[
|
||||
"bin",
|
||||
"pkg",
|
||||
"vendor",
|
||||
"Cargo.lock",
|
||||
"*.gem",
|
||||
".bundle",
|
||||
"vendor/bundle",
|
||||
@@ -217,7 +219,6 @@ const IGNORED_NAMES: &[&str] = &[
|
||||
"DerivedDataCache",
|
||||
"Intermediate",
|
||||
"Saved",
|
||||
"*.lock",
|
||||
"*.7z",
|
||||
];
|
||||
|
||||
@@ -531,10 +532,9 @@ fn normalize_store(store: &mut StoreData) {
|
||||
}
|
||||
|
||||
for folder in &mut store.folders {
|
||||
let repo_exists = Path::new(&folder.path).join(".git").exists();
|
||||
let path_exists = Path::new(&folder.path).exists();
|
||||
folder.needs_relocation = !path_exists;
|
||||
if !repo_exists || folder.needs_relocation {
|
||||
if folder.needs_relocation {
|
||||
folder.monitoring = false;
|
||||
}
|
||||
// A persisted flag can only describe a process from a previous app lifetime. Never resume
|
||||
@@ -967,16 +967,46 @@ fn stream_ollama(
|
||||
temperature: f64,
|
||||
app: &AppHandle,
|
||||
) -> CommandResult<String> {
|
||||
stream_ollama_request(prompt, model, temperature, app, false)
|
||||
stream_ollama_request(prompt, model, temperature, app, None)
|
||||
}
|
||||
|
||||
fn stream_ollama_json(
|
||||
prompt: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
hashes: &[String],
|
||||
app: &AppHandle,
|
||||
) -> CommandResult<String> {
|
||||
stream_ollama_request(prompt, model, temperature, app, true)
|
||||
stream_ollama_request(
|
||||
prompt,
|
||||
model,
|
||||
temperature,
|
||||
app,
|
||||
Some(commit_message_json_schema(hashes)),
|
||||
)
|
||||
}
|
||||
|
||||
fn commit_message_json_schema(hashes: &[String]) -> Value {
|
||||
let mut properties = serde_json::Map::new();
|
||||
let mut required = Vec::new();
|
||||
for hash in hashes {
|
||||
let short = short_hash(hash);
|
||||
properties.insert(
|
||||
short.clone(),
|
||||
json!({
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "A concise single-line commit message"
|
||||
}),
|
||||
);
|
||||
required.push(short);
|
||||
}
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn stream_ollama_request(
|
||||
@@ -984,7 +1014,7 @@ fn stream_ollama_request(
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
app: &AppHandle,
|
||||
json_mode: bool,
|
||||
response_format: Option<Value>,
|
||||
) -> CommandResult<String> {
|
||||
ensure_ollama_running()?;
|
||||
emit(app, "cat-begin", ());
|
||||
@@ -997,10 +1027,14 @@ fn stream_ollama_request(
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": true,
|
||||
"options": { "temperature": temperature }
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_ctx": OLLAMA_COMMIT_CONTEXT_TOKENS,
|
||||
"num_predict": 2048
|
||||
}
|
||||
});
|
||||
if json_mode {
|
||||
payload["format"] = json!("json");
|
||||
if let Some(response_format) = response_format {
|
||||
payload["format"] = response_format;
|
||||
}
|
||||
let mut response = client
|
||||
.post(format!("{OLLAMA_BASE_URL}/api/generate"))
|
||||
@@ -1081,7 +1115,7 @@ fn generate_llm_message_for_commit(
|
||||
) -> CommandResult<String> {
|
||||
let hashes = vec![hash.to_string()];
|
||||
let prompt = generate_llm_commit_prompt(folder_path, &hashes)?;
|
||||
let llm_raw = stream_ollama_json(&prompt, model, 0.3, app)?;
|
||||
let llm_raw = stream_ollama_json(&prompt, model, 0.3, &hashes, app)?;
|
||||
let parsed = parse_llm_commit_messages(&llm_raw)?;
|
||||
let validated = validate_llm_commit_messages(parsed, &hashes)?;
|
||||
validated.get(hash).cloned().ok_or_else(|| {
|
||||
@@ -1092,15 +1126,26 @@ fn generate_llm_message_for_commit(
|
||||
})
|
||||
}
|
||||
|
||||
fn truncate_prompt_text(value: &str, max_chars: usize) -> String {
|
||||
let Some((cutoff, _)) = value.char_indices().nth(max_chars) else {
|
||||
return value.to_string();
|
||||
};
|
||||
format!("{}\n[diff truncated by Auto-Git]", &value[..cutoff])
|
||||
}
|
||||
|
||||
fn get_commits_for_llm(folder_path: &str, hashes: &[String]) -> CommandResult<Vec<Value>> {
|
||||
let mut commits = Vec::new();
|
||||
let diff_budget = MAX_COMMIT_DIFF_TOTAL_CHARS / hashes.len().max(1);
|
||||
for hash in hashes {
|
||||
let diff = run_git(folder_path, &["diff", &format!("{hash}^!")])?;
|
||||
let diff = run_git(
|
||||
folder_path,
|
||||
&["show", "--format=", "--no-ext-diff", "--unified=2", hash],
|
||||
)?;
|
||||
let msg = run_git(folder_path, &["show", "-s", "--format=%B", hash])?;
|
||||
commits.push(json!({
|
||||
"hash": short_hash(hash),
|
||||
"message": msg.trim(),
|
||||
"diff": diff
|
||||
"diff": truncate_prompt_text(&diff, diff_budget)
|
||||
}));
|
||||
}
|
||||
Ok(commits)
|
||||
@@ -1124,10 +1169,11 @@ COMMITS (as JSON):
|
||||
{}"#,
|
||||
serde_json::to_string_pretty(&commits).map_err(|e| e.to_string())?
|
||||
);
|
||||
if prompt.len() > 200_000 {
|
||||
if prompt.chars().count() > MAX_COMMIT_MESSAGE_PROMPT_CHARS {
|
||||
return Err(format!(
|
||||
"LLM prompt too large ({} chars) for {folder_path}",
|
||||
prompt.len()
|
||||
"Commit-message prompt is still too large after bounded diff truncation ({} chars; maximum {}). No LLM request was sent for {folder_path}.",
|
||||
prompt.chars().count(),
|
||||
MAX_COMMIT_MESSAGE_PROMPT_CHARS
|
||||
));
|
||||
}
|
||||
Ok(prompt)
|
||||
@@ -1375,7 +1421,7 @@ fn set_executable(_path: &Path) -> CommandResult<()> {
|
||||
fn run_llm_commit_rewrite(app: AppHandle, folder_path: String) -> CommandResult<()> {
|
||||
let state = app.state::<AppState>();
|
||||
let _operation = begin_repo_operation(&state, &folder_path, "history rewrite")?;
|
||||
let (hashes, original_birthday) = {
|
||||
let hashes = {
|
||||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||||
let Some(folder) = store.folders.iter_mut().find(|f| f.path == folder_path) else {
|
||||
return Ok(());
|
||||
@@ -1395,15 +1441,18 @@ fn run_llm_commit_rewrite(app: AppHandle, folder_path: String) -> CommandResult<
|
||||
return Err("Another rewrite is already running for this repository.".to_string());
|
||||
}
|
||||
let hashes = folder.llm_candidates.clone();
|
||||
let original_birthday = folder.first_candidate_birthday;
|
||||
folder.rewrite_in_progress = true;
|
||||
folder.rewrite_started_at = Some(now_ms());
|
||||
(hashes, original_birthday)
|
||||
hashes
|
||||
};
|
||||
save_store(&state)?;
|
||||
|
||||
let mut error: Option<String> = None;
|
||||
let result = (|| -> CommandResult<()> {
|
||||
// Do every Git safety check that can block the rewrite before issuing an LLM request.
|
||||
// A dirty index/worktree, detached HEAD, conflict, or in-progress Git operation must cost
|
||||
// zero model calls.
|
||||
safe_repo_snapshot(&folder_path, true)?;
|
||||
let prompt = generate_llm_commit_prompt(&folder_path, &hashes)?;
|
||||
let model = {
|
||||
let store = state.store.lock().map_err(|e| e.to_string())?;
|
||||
@@ -1412,7 +1461,7 @@ fn run_llm_commit_rewrite(app: AppHandle, folder_path: String) -> CommandResult<
|
||||
.clone()
|
||||
.unwrap_or_else(|| "qwen2.5-coder:7b".to_string())
|
||||
};
|
||||
let llm_raw = stream_ollama_json(&prompt, &model, 0.3, &app)?;
|
||||
let llm_raw = stream_ollama_json(&prompt, &model, 0.3, &hashes, &app)?;
|
||||
let message_map =
|
||||
validate_llm_commit_messages(parse_llm_commit_messages(&llm_raw)?, &hashes)?;
|
||||
reword_commits_transactionally(&folder_path, &message_map, &hashes)?;
|
||||
@@ -1448,13 +1497,7 @@ fn run_llm_commit_rewrite(app: AppHandle, folder_path: String) -> CommandResult<
|
||||
let buffer = folder.llm_buffer.clone();
|
||||
if error.is_some() {
|
||||
folder.llm_candidates = retry_candidates;
|
||||
folder.first_candidate_birthday = original_birthday.or_else(|| {
|
||||
if folder.llm_candidates.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(now_ms())
|
||||
}
|
||||
});
|
||||
pause_automatic_rewrite_after_failure(folder);
|
||||
} else {
|
||||
folder.llm_candidates = buffer;
|
||||
folder.first_candidate_birthday = if folder.llm_candidates.is_empty() {
|
||||
@@ -1472,6 +1515,20 @@ fn run_llm_commit_rewrite(app: AppHandle, folder_path: String) -> CommandResult<
|
||||
save_store(&state)?;
|
||||
emit(&app, "repo-updated", folder_path.clone());
|
||||
|
||||
if let Some(err) = error.as_deref() {
|
||||
emit(
|
||||
&app,
|
||||
"monitoring-error",
|
||||
json!({
|
||||
"path": folder_path,
|
||||
"code": "AUTO_REWRITE_PAUSED",
|
||||
"message": format!(
|
||||
"Automatic commit-message rewriting was paused after one failed attempt. No automatic retry will run until a new commit is recorded or you explicitly retry. {err}"
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
@@ -1492,6 +1549,11 @@ fn automatic_rewrite_eligible(folder: &FolderObj) -> bool {
|
||||
&& !folder.llm_candidates.is_empty()
|
||||
}
|
||||
|
||||
fn pause_automatic_rewrite_after_failure(folder: &mut FolderObj) {
|
||||
folder.lines_changed = 0;
|
||||
folder.first_candidate_birthday = None;
|
||||
}
|
||||
|
||||
fn line_rewrite_due(folder: &FolderObj, threshold: i64) -> bool {
|
||||
automatic_rewrite_eligible(folder) && folder.lines_changed >= threshold
|
||||
}
|
||||
@@ -1629,7 +1691,7 @@ fn auto_commit(app: AppHandle, folder_path: String, mut paths: Vec<String>) -> C
|
||||
folder.llm_buffer.push(new_head.clone());
|
||||
} else {
|
||||
folder.llm_candidates.push(new_head.clone());
|
||||
if folder.llm_candidates.len() == 1 {
|
||||
if folder.first_candidate_birthday.is_none() {
|
||||
folder.first_candidate_birthday = Some(now_ms());
|
||||
}
|
||||
}
|
||||
@@ -4883,6 +4945,63 @@ mod tests {
|
||||
assert!(!time_rewrite_due(&folder, 1, 120_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_automatic_rewrite_is_not_retried_by_the_scheduler() {
|
||||
let mut folder = FolderObj {
|
||||
path: "/tmp/example".to_string(),
|
||||
monitoring: true,
|
||||
needs_relocation: false,
|
||||
lines_changed: 1_543,
|
||||
llm_candidates: vec!["a".repeat(40)],
|
||||
llm_buffer: Vec::new(),
|
||||
first_candidate_birthday: Some(60_000),
|
||||
last_head_hash: None,
|
||||
rewrite_in_progress: false,
|
||||
rewrite_started_at: None,
|
||||
};
|
||||
|
||||
pause_automatic_rewrite_after_failure(&mut folder);
|
||||
|
||||
assert_eq!(folder.lines_changed, 0);
|
||||
assert_eq!(folder.first_candidate_birthday, None);
|
||||
assert!(!line_rewrite_due(&folder, 10));
|
||||
assert!(!time_rewrite_due(&folder, 1, 120_000));
|
||||
assert_eq!(folder.llm_candidates.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_commit_output_requires_every_requested_hash() {
|
||||
let hashes = vec!["aaaaaaa111111111111111111111111111111111".to_string()];
|
||||
let schema = commit_message_json_schema(&hashes);
|
||||
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert_eq!(schema["additionalProperties"], false);
|
||||
assert_eq!(schema["required"], json!(["aaaaaaa"]));
|
||||
assert_eq!(schema["properties"]["aaaaaaa"]["type"], "string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_non_repo_can_remain_armed_for_monitoring() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut store = StoreData::default();
|
||||
store.folders.push(FolderObj {
|
||||
path: temp.path().to_string_lossy().to_string(),
|
||||
monitoring: true,
|
||||
needs_relocation: false,
|
||||
lines_changed: 0,
|
||||
llm_candidates: Vec::new(),
|
||||
llm_buffer: Vec::new(),
|
||||
first_candidate_birthday: None,
|
||||
last_head_hash: None,
|
||||
rewrite_in_progress: false,
|
||||
rewrite_started_at: None,
|
||||
});
|
||||
|
||||
normalize_store(&mut store);
|
||||
|
||||
assert!(store.folders[0].monitoring);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_summary_uses_the_javascript_field_names() {
|
||||
let summary = CommitSummary {
|
||||
@@ -4971,6 +5090,19 @@ mod tests {
|
||||
assert!(parse_llm_commit_messages("```json\n{\"aaaaaaa\":\"message\"}\n```").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_message_prompt_is_bounded_before_any_model_call() {
|
||||
let temp = init_test_repo();
|
||||
let path = temp.path().to_str().unwrap();
|
||||
let contents = "let generated_value = 1;\n".repeat(10_000);
|
||||
let hash = test_commit(path, "large.rs", &contents, "auto-git: large change");
|
||||
|
||||
let prompt = generate_llm_commit_prompt(path, &[hash]).unwrap();
|
||||
|
||||
assert!(prompt.chars().count() <= MAX_COMMIT_MESSAGE_PROMPT_CHARS);
|
||||
assert!(prompt.contains("[diff truncated by Auto-Git]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_commit_can_be_reworded_without_a_parent_revision() {
|
||||
let temp = init_test_repo();
|
||||
@@ -5186,6 +5318,10 @@ mod tests {
|
||||
root,
|
||||
&temp.path().join("node_modules/package/index.js")
|
||||
));
|
||||
assert!(!is_default_ignored(
|
||||
root,
|
||||
&temp.path().join("src-tauri/Cargo.lock")
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Auto-Git",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.8",
|
||||
"identifier": "com.victorgiers.auto-git",
|
||||
"build": {
|
||||
"frontendDist": "../dist-tauri"
|
||||
|
||||
Reference in New Issue
Block a user