优化: AI治理与性能(tool_failure_stats失败率统计命令 + git status仓库级缓存治cmd闪烁N²) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:02 +08:00
parent e099eff4cb
commit 5a92e6056c
6 changed files with 375 additions and 25 deletions
+113 -21
View File
@@ -15,6 +15,8 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tauri::State;
@@ -209,6 +211,10 @@ pub async fn update_project_module(
if path.is_empty() {
return Err("path 不能为空".to_string());
}
// path 变更 → 清旧路径的 git status 缓存(避免后续同路径复用脏状态)
if existing.path != path {
invalidate_git_status_cache(&existing.path);
}
existing.path = path;
}
existing.git_url = trim_opt(input.git_url);
@@ -245,6 +251,10 @@ pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Re
if id.is_empty() {
return Err("id 不能为空".to_string());
}
// 删除前取 path,删除后清 git status 缓存(避免同路径复用脏状态)
if let Ok(Some(module)) = state.project_modules.get_by_id(&id).await {
invalidate_git_status_cache(&module.path);
}
state.project_modules.delete(&id).await.map_err(err_str)?;
Ok(())
}
@@ -301,6 +311,61 @@ pub async fn list_project_modules(
Ok(modules)
}
// ============================================================
// 仓库级 git status 缓存(P1-f:cmd 闪烁 N² 修复)
// ============================================================
/// git status 缓存 TTL(5 秒)。
///
/// 背景:`get_module_file_tree` 展开新目录 + `get_module_git_status` 切换变更视图都会在仓库根
/// 跑整仓 `git status --porcelain`;前端 `loadedChildren` 只对「同目录展开」去重,跨目录展开
/// N 次 = N 次整仓扫描(cmd 闪烁 N²)。git status 天然幂等(输出反映当前工作区),TTL 秒级失效
/// 可接受(用户改文件后刷新触发重扫兜底),不做实时 watcher(过度设计)。
const GIT_STATUS_CACHE_TTL: Duration = Duration::from_secs(5);
/// 仓库根路径 → (采集时间戳, git status --porcelain 输出 map)。
/// 键 = module.path(仓库根);值 = (Instant, {posix_rel: status})。
/// 进程级共享(spawn_blocking 内 std Mutex 访问),跨 IPC 请求复用。
static GIT_STATUS_CACHE: LazyLock<Mutex<HashMap<String, (Instant, HashMap<String, String>)>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// 判断缓存条目是否新鲜(TTL 内)。抽出纯函数便于单测。
fn is_git_status_cache_fresh(collected_at: Instant, now: Instant) -> bool {
now.duration_since(collected_at) < GIT_STATUS_CACHE_TTL
}
/// 取仓库 git status map:TTL 内命中直接复用缓存,未命中/过期才跑 git status 并更新缓存。
/// 非 git 仓库 → 空 map 且不入缓存(.git 判断廉价,且避免「git init 后 TTL 内误返空」)。
fn git_status_map_cached(dir: &str) -> HashMap<String, String> {
// 非 git 仓库快速返回(不缓存:git init 后 TTL 内重扫才拿得到真实状态)
if !Path::new(dir).join(".git").exists() {
return HashMap::new();
}
let now = Instant::now();
// 命中 TTL 内缓存 → 直接复用(锁内只查表,不跑 git,快速返回)
{
let cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((ts, map)) = cache.get(dir) {
if is_git_status_cache_fresh(*ts, now) {
return map.clone();
}
}
}
// 未命中/过期 → 跑 git(锁外执行,避免持锁阻塞其他目录的缓存命中;完成后更新缓存)
let map = collect_git_status_map(dir);
let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
cache.insert(dir.to_string(), (now, map.clone()));
map
}
/// 清空指定仓库根的 git status 缓存条目(工程删除 / path 变更时防脏数据复用)。
fn invalidate_git_status_cache(dir: &str) {
GIT_STATUS_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(dir);
}
// ============================================================
// IPC 命令 — Git 状态查询(实时派生,不进表)
// ============================================================
@@ -448,8 +513,6 @@ pub async fn list_branches(
///
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
fn run_git_status(dir: &str) -> GitStatus {
use std::time::Duration;
let path = std::path::Path::new(dir);
if !path.join(".git").exists() {
return empty_status();
@@ -463,24 +526,14 @@ fn run_git_status(dir: &str) -> GitStatus {
.filter(|s| !s.is_empty() && s != "HEAD")
.unwrap_or_default();
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)
let mut changed_files = Vec::new();
if let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) {
for line in out.lines() {
if line.len() < 4 {
continue;
}
// porcelain 格式:"XY path"(X/Y 各一字符 + 一空格 + path)
let status = line[..2].to_string();
let file_path = line[3..].trim().to_string();
if !file_path.is_empty() {
changed_files.push(GitChangedFile {
status,
path: file_path,
});
}
}
}
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)
// 走仓库级缓存(与文件树共用同一份扫描,TTL 内展开/切换不再整仓重扫)。
let mut changed_files: Vec<GitChangedFile> = git_status_map_cached(dir)
.into_iter()
.map(|(path, status)| GitChangedFile { status, path })
.collect();
// map 无序,按路径排序保证前端展示顺序稳定(git status 输出本身即按路径有序)。
changed_files.sort_by(|a, b| a.path.cmp(&b.path));
// 3) 最近提交:`git log -50 --format="%h %ct %an %s"`(hash + 时间戳 + 作者 + subject)
let mut recent_commits = Vec::new();
@@ -652,7 +705,7 @@ pub async fn get_module_file_tree(
// 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。
// 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。
let dir_for_git = module.path.clone();
let git_map = tokio::task::spawn_blocking(move || collect_git_status_map(&dir_for_git))
let git_map = tokio::task::spawn_blocking(move || git_status_map_cached(&dir_for_git))
.await
.map_err(|e| format!("git status 采集任务失败: {e}"))?;
// 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。
@@ -1362,3 +1415,42 @@ pub async fn detect_module_cycles(
}
Ok(cycle_nodes.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
/// P1-f:git status 缓存 TTL 判定 — 5s 内新鲜,超时失效。
#[test]
fn test_is_git_status_cache_fresh() {
let now = Instant::now();
// TTL 内 → 新鲜
assert!(is_git_status_cache_fresh(now - Duration::from_millis(4000), now));
// 恰好 TTL → 不新鲜(duration_since < TTL 严格小于)
assert!(!is_git_status_cache_fresh(now - Duration::from_secs(5), now));
// 超过 TTL → 失效
assert!(!is_git_status_cache_fresh(now - Duration::from_secs(6), now));
}
/// P1-f:git status 缓存同一仓库路径复用同一 map 条目、不同路径独立(纯逻辑,不经 git)。
#[test]
fn test_git_status_cache_shared_per_repo() {
let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
let mut map_a = HashMap::new();
map_a.insert("src/main.rs".to_string(), " M".to_string());
let mut map_b = HashMap::new();
map_b.insert("README.md".to_string(), "??".to_string());
cache.insert("repoA".to_string(), (now, map_a.clone()));
cache.insert("repoB".to_string(), (now, map_b.clone()));
// 同路径命中同一份缓存,不同路径互不串扰
let (_, cached_a) = cache.get("repoA").unwrap();
assert_eq!(cached_a, &map_a);
assert_ne!(cached_a, &map_b);
// 清理后不再命中
cache.remove("repoA");
assert!(cache.get("repoA").is_none());
}
}