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
This commit is contained in:
+72
-95
@@ -578,6 +578,25 @@ fn repository_bundle_for_repo(
|
||||
// branches -> tags -> stashes -> commits -> files waterfall down to the
|
||||
// duration of its slowest member.
|
||||
let status = status_for_repo(repo)?;
|
||||
|
||||
// A freshly initialized or cloned empty repository has a symbolic HEAD,
|
||||
// but it does not resolve to a commit yet (an "unborn" HEAD). Some Git
|
||||
// commands and Git extensions treat that as a hard revision error. Keep
|
||||
// the repository usable and still report any untracked working-tree files
|
||||
// without starting commit-dependent workers.
|
||||
if verify_commit(repo, "HEAD").is_err() {
|
||||
let files = repository_files_with_status(repo, &status)?;
|
||||
return Ok(RepositoryBundle {
|
||||
status,
|
||||
branches: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
stashes: Vec::new(),
|
||||
commits: Vec::new(),
|
||||
files,
|
||||
warning: None,
|
||||
});
|
||||
}
|
||||
|
||||
let (branches, tags, stashes, commits, files) = thread::scope(|scope| -> Result<_, String> {
|
||||
let branches = scope.spawn(|| branches_for_repo(repo));
|
||||
let tags = scope.spawn(|| tags_for_repo(repo));
|
||||
@@ -2117,28 +2136,6 @@ pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String
|
||||
Ok(String::from_utf8_lossy(&output).to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn commit_ai_local_models() -> Vec<commit_ai::LocalModelOption> {
|
||||
commit_ai::LOCAL_MODELS.to_vec()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn commit_ai_status(
|
||||
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
|
||||
) -> Result<commit_ai::CommitAiStatus, String> {
|
||||
Ok(engine.status().await)
|
||||
}
|
||||
|
||||
/// Kicks off the (first-run-only) download and model load in the background and returns
|
||||
/// immediately; the frontend polls `commit_ai_status` to know when it's ready.
|
||||
#[tauri::command]
|
||||
pub fn commit_ai_load(model_id: String, engine: tauri::State<'_, commit_ai::CommitAiEngine>) {
|
||||
let engine = engine.inner().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
engine.ensure_loaded(&model_id).await;
|
||||
});
|
||||
}
|
||||
|
||||
// The prompt is built from `git diff --cached` only, i.e. exactly the staged changes —
|
||||
// unstaged edits and untracked files never influence the generated message.
|
||||
fn staged_diff(repo: &Path) -> Result<String, String> {
|
||||
@@ -2147,8 +2144,8 @@ fn staged_diff(repo: &Path) -> Result<String, String> {
|
||||
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
|
||||
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
|
||||
|
||||
// Generated lockfiles say nothing useful about intent but easily blow the small
|
||||
// context window of local models, so keep them out of the detailed diff.
|
||||
// Generated lockfiles say little about intent and can easily dominate the context,
|
||||
// so keep them out of the detailed diff.
|
||||
let diff = run_git(
|
||||
repo,
|
||||
[
|
||||
@@ -2178,50 +2175,6 @@ fn staged_diff(repo: &Path) -> Result<String, String> {
|
||||
Ok(format!("Staged files:\n{file_list}\n\n{diff}"))
|
||||
}
|
||||
|
||||
fn staged_diff_local(
|
||||
repo: &Path,
|
||||
profile: commit_ai::LocalGenerationProfile,
|
||||
) -> Result<String, String> {
|
||||
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
|
||||
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
|
||||
|
||||
let stat = run_git(repo, ["diff", "--cached", "--stat", "--summary"])?;
|
||||
let stat = String::from_utf8_lossy(&stat).trim().to_string();
|
||||
|
||||
let diff_args = vec![
|
||||
"diff",
|
||||
"--cached",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
profile.diff_unified_context(),
|
||||
"--",
|
||||
".",
|
||||
":(exclude)*package-lock.json",
|
||||
":(exclude)*pnpm-lock.yaml",
|
||||
":(exclude)*yarn.lock",
|
||||
":(exclude)*bun.lockb",
|
||||
":(exclude)*Cargo.lock",
|
||||
":(exclude)*composer.lock",
|
||||
":(exclude)*Gemfile.lock",
|
||||
":(exclude)*poetry.lock",
|
||||
":(exclude)*go.sum",
|
||||
];
|
||||
let diff = run_git(repo, diff_args)?;
|
||||
let diff = String::from_utf8_lossy(&diff).trim().to_string();
|
||||
|
||||
let mut sections = Vec::new();
|
||||
if !file_list.is_empty() {
|
||||
sections.push(format!("Staged files:\n{file_list}"));
|
||||
}
|
||||
if !stat.is_empty() {
|
||||
sections.push(format!("Diff stat:\n{stat}"));
|
||||
}
|
||||
if !diff.is_empty() {
|
||||
sections.push(format!("Detailed diff:\n{diff}"));
|
||||
}
|
||||
Ok(sections.join("\n\n"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn commit_ai_generate(
|
||||
path: String,
|
||||
@@ -2230,27 +2183,15 @@ pub async fn commit_ai_generate(
|
||||
model: Option<String>,
|
||||
api_key: Option<String>,
|
||||
base_url: Option<String>,
|
||||
local_profile: Option<String>,
|
||||
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
|
||||
) -> Result<String, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let local_profile = commit_ai::LocalGenerationProfile::from_id(local_profile.as_deref());
|
||||
let diff = if provider == "local" {
|
||||
staged_diff_local(&repo, local_profile)?
|
||||
} else {
|
||||
staged_diff(&repo)?
|
||||
};
|
||||
let diff = staged_diff(&repo)?;
|
||||
let notes = notes.as_deref();
|
||||
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());
|
||||
|
||||
match provider.as_str() {
|
||||
"local" => {
|
||||
engine
|
||||
.generate_commit_message(&diff, notes, local_profile)
|
||||
.await
|
||||
}
|
||||
"openai" => {
|
||||
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
|
||||
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
|
||||
@@ -2420,7 +2361,6 @@ pub async fn commit_ai_split(
|
||||
)
|
||||
.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)
|
||||
@@ -2522,7 +2462,6 @@ pub async fn commit_ai_review(
|
||||
let model = model.ok_or_else(|| "Model name is missing.".to_string())?;
|
||||
commit_ai::review_custom(&base_url, api_key.as_deref(), &model, &diff).await?
|
||||
}
|
||||
"local" => return Err("Pre-commit review currently requires an API provider.".to_string()),
|
||||
other => return Err(format!("Unknown AI provider: {other}")),
|
||||
};
|
||||
parse_ai_review(&raw)
|
||||
@@ -5481,19 +5420,26 @@ fn clone_repository_core(
|
||||
run_git_clone(remote_url.trim(), &target, username, password)?;
|
||||
|
||||
let repo = resolve_repo(&target.to_string_lossy())?;
|
||||
let lfs_warning = sync_git_lfs_objects_if_needed(
|
||||
&repo,
|
||||
Some("origin"),
|
||||
username,
|
||||
password,
|
||||
true,
|
||||
)
|
||||
.err()
|
||||
.map(|error| {
|
||||
format!(
|
||||
"Repository cloned, but Git LFS objects could not be downloaded automatically: {error}"
|
||||
let lfs_warning = if verify_commit(&repo, "HEAD").is_ok() {
|
||||
sync_git_lfs_objects_if_needed(
|
||||
&repo,
|
||||
Some("origin"),
|
||||
username,
|
||||
password,
|
||||
true,
|
||||
)
|
||||
});
|
||||
.err()
|
||||
.map(|error| {
|
||||
format!(
|
||||
"Repository cloned, but Git LFS objects could not be downloaded automatically: {error}"
|
||||
)
|
||||
})
|
||||
} else {
|
||||
// There cannot be LFS pointers to download before the first commit.
|
||||
// In particular, avoid Git LFS implementations that try to resolve
|
||||
// HEAD themselves and fail on an empty repository.
|
||||
None
|
||||
};
|
||||
|
||||
let mut bundle = repository_bundle_for_repo(&repo, commit_limit)?;
|
||||
bundle.warning = lfs_warning;
|
||||
@@ -7840,6 +7786,37 @@ mod tests {
|
||||
assert!(bundle.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_repository_core_supports_empty_repository() {
|
||||
let source = init_bare_temp_repo("empty_clone_source");
|
||||
let parent = temp_dir("empty_clone_parent");
|
||||
|
||||
let bundle = clone_repository_core(
|
||||
source.path.to_str().expect("source path should be UTF-8"),
|
||||
parent.path.to_str().expect("parent path should be UTF-8"),
|
||||
Some("local-copy"),
|
||||
None,
|
||||
None,
|
||||
Some(100),
|
||||
)
|
||||
.expect("empty repository should clone");
|
||||
|
||||
let cloned_repo = parent.path.join("local-copy");
|
||||
assert_eq!(
|
||||
PathBuf::from(bundle.status.repo_path),
|
||||
cloned_repo
|
||||
.canonicalize()
|
||||
.expect("clone path should resolve")
|
||||
);
|
||||
assert!(bundle.status.clean);
|
||||
assert!(bundle.commits.is_empty());
|
||||
assert!(bundle.branches.is_empty());
|
||||
assert!(bundle.tags.is_empty());
|
||||
assert!(bundle.stashes.is_empty());
|
||||
assert!(bundle.files.is_empty());
|
||||
assert!(bundle.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
|
||||
Reference in New Issue
Block a user