R1 管道自建成以来是死链:buff 与 ingest 两处 git log 都用 %s 只取 subject,trailer 在 body 里永远读不到(炼境自身 11 条带 trailer 的 commit,DB rules_applied 全空);单测喂全文所以全绿。 - GIT_LOG_FMT 加 %(trailers:key=Rules-Applied,valueonly,separator=%x2C), git 原生 trailer 解析,保持单行一条记录 - read_git_log 升为增量/全量统一取数入口(GitLogRange),buff 的 get_new_commits 委托之,消灭两处重复 format 定义 - record_commit_entry + upsert_metrics:ON CONFLICT 仅回填 rules_applied IS NULL 的存量行,绝不覆盖已有值,回填幂等 - ingest_full_git_history 改走 record_commit_entry,消灭第三处重复 INSERT - get_new_commits 的 WSL 分支随 2026-07-04 裁决顺手清理 - 测试:真 git 仓库 e2e trailer 提取回归 + UPSERT 四态语义,82 全绿 Rules-Applied: R06,R07,R13
440 lines
16 KiB
Rust
440 lines
16 KiB
Rust
use serde::{Deserialize, Serialize};
|
||
use std::path::Path;
|
||
use tauri::AppHandle;
|
||
|
||
// ── 数据结构 ─────────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||
pub struct BuffStatus {
|
||
pub project_path: String,
|
||
pub project_id: String,
|
||
pub applied_at: String,
|
||
pub last_sync_at: Option<String>,
|
||
pub last_commit: Option<String>,
|
||
pub status: String,
|
||
}
|
||
|
||
// ── Tauri 命令 ───────────────────────────────────────────────────────────────
|
||
|
||
/// 向项目施加蓝图 Buff:注入 CONVENTIONS + CLAUDE.md,写入 DB
|
||
#[tauri::command]
|
||
pub fn apply_blueprint_buff(
|
||
project_path: String,
|
||
project_id: String,
|
||
) -> Result<BuffStatus, String> {
|
||
let path = project_path.trim_end_matches(['/', '\\']).to_string();
|
||
|
||
// 注入蓝图规则(CONVENTIONS.md + CLAUDE.md)
|
||
crate::commands::blueprint::sync_blueprint_rules(path.clone())
|
||
.map_err(|e| format!("注入蓝图规则失败: {e}"))?;
|
||
|
||
// 独立项目自动创建产品组(含 MCP 注入)
|
||
if let Err(e) = crate::commands::groups::ensure_standalone_group(&project_id) {
|
||
// 非致命,不阻断 Buff 应用
|
||
crate::db::log_event("buff", "warn", &format!("独立产品组创建失败: {e}"), None);
|
||
}
|
||
|
||
// 写入 DB
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO blueprint_buffs
|
||
(project_path, project_id, applied_at, status)
|
||
VALUES (?1, ?2, datetime('now'), 'active')",
|
||
rusqlite::params![path, project_id],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
query_buff(&conn, &path)
|
||
}
|
||
|
||
/// 撤销蓝图 Buff
|
||
#[tauri::command]
|
||
pub fn remove_blueprint_buff(project_path: String) -> Result<(), String> {
|
||
let path = project_path.trim_end_matches(['/', '\\']).to_string();
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
conn.execute(
|
||
"DELETE FROM blueprint_buffs WHERE project_path = ?1",
|
||
rusqlite::params![path],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 查询单个项目的 Buff 状态
|
||
#[tauri::command]
|
||
pub fn get_buff_status(project_path: String) -> Result<Option<BuffStatus>, String> {
|
||
let path = project_path.trim_end_matches(['/', '\\']).to_string();
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
match query_buff(&conn, &path) {
|
||
Ok(s) => Ok(Some(s)),
|
||
Err(_) => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// 列出所有已施加 Buff 的项目
|
||
#[tauri::command]
|
||
pub fn list_buffed_projects() -> Result<Vec<BuffStatus>, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT project_path, project_id, applied_at, last_sync_at, last_commit, status
|
||
FROM blueprint_buffs WHERE status = 'active'",
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let items = stmt
|
||
.query_map([], |row| {
|
||
Ok(BuffStatus {
|
||
project_path: row.get(0)?,
|
||
project_id: row.get(1)?,
|
||
applied_at: row.get(2)?,
|
||
last_sync_at: row.get(3)?,
|
||
last_commit: row.get(4)?,
|
||
status: row.get(5)?,
|
||
})
|
||
})
|
||
.map_err(|e| e.to_string())?
|
||
.filter_map(|r| r.ok())
|
||
.collect();
|
||
|
||
Ok(items)
|
||
}
|
||
|
||
// ── 后台 git 轮询(T3)──────────────────────────────────────────────────────
|
||
|
||
/// App 启动时调用,启动后台轮询任务(每 5 秒检查所有 buffed 项目的 git HEAD)
|
||
pub fn start_blueprint_watchers(app: AppHandle) {
|
||
tauri::async_runtime::spawn(async move {
|
||
loop {
|
||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||
poll_all_buffs(&app);
|
||
}
|
||
});
|
||
}
|
||
|
||
fn poll_all_buffs(app: &AppHandle) {
|
||
let paths = match list_active_paths() {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
for path in paths {
|
||
let _ = check_project(app, &path);
|
||
}
|
||
}
|
||
|
||
fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> {
|
||
let root = Path::new(project_path);
|
||
|
||
// 获取当前 HEAD commit
|
||
let current_commit = match get_head_commit(root) {
|
||
Ok(c) => c,
|
||
Err(_) => return Ok(()), // 不是 git 仓库或无提交,跳过
|
||
};
|
||
|
||
// 查询 project_id 与上次已处理的 commit
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
let (project_id, last_commit): (String, Option<String>) = conn
|
||
.query_row(
|
||
"SELECT project_id, last_commit FROM blueprint_buffs WHERE project_path = ?1",
|
||
rusqlite::params![project_path],
|
||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||
)
|
||
.unwrap_or_else(|_| (String::new(), None));
|
||
|
||
// 没有变化,跳过
|
||
if last_commit.as_deref() == Some(current_commit.as_str()) {
|
||
return Ok(());
|
||
}
|
||
|
||
// 飞轮 L0:解析新增 commit 的 conventional 指标写 commit_metrics(与下方文件匹配互不干扰)
|
||
if !project_id.is_empty() {
|
||
let first_time = last_commit.is_none();
|
||
let commits = get_new_commits(root, last_commit.as_deref());
|
||
if first_time && commits.len() >= FIRST_TIME_LIMIT {
|
||
crate::db::log_event(
|
||
"buff",
|
||
"info",
|
||
&format!("首次接入仅解析最近 {FIRST_TIME_LIMIT} 条 commit,更早历史跳过"),
|
||
Some(project_path),
|
||
);
|
||
}
|
||
// 批量写入用事务包裹(首次可达数十条,避免逐条 fsync)
|
||
let tx = conn.unchecked_transaction();
|
||
for entry in &commits {
|
||
let _ = crate::commands::commit_metrics::record_commit_entry(&conn, &project_id, entry);
|
||
}
|
||
if let Ok(tx) = tx {
|
||
let _ = tx.commit();
|
||
}
|
||
}
|
||
|
||
// 文件路径匹配 → 标记命中的任务卡 🔵
|
||
let changed = get_changed_files(root, last_commit.as_deref())?;
|
||
let mut tasks_updated = false;
|
||
if !changed.is_empty() && update_task_statuses(root, &changed)? > 0 {
|
||
tasks_updated = true;
|
||
}
|
||
|
||
// 有新 commit 即刷新飞轮快照(rework_count 可能已变);任务卡有变动则通知前端重绘
|
||
let _ = crate::commands::blueprint::get_blueprint(project_path.to_string());
|
||
if tasks_updated {
|
||
use tauri::Emitter;
|
||
let _ = app.emit(
|
||
"blueprint://changed",
|
||
serde_json::json!({ "project_path": project_path }),
|
||
);
|
||
}
|
||
|
||
// 更新 DB:记录已处理的 commit
|
||
let _ = conn.execute(
|
||
"UPDATE blueprint_buffs
|
||
SET last_commit = ?1, last_sync_at = datetime('now')
|
||
WHERE project_path = ?2",
|
||
rusqlite::params![current_commit, project_path],
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ── 辅助:WSL 路径检测与转换 ─────────────────────────────────────────────────
|
||
|
||
/// 判断路径是否为 WSL UNC 路径(\\wsl.localhost\... 或 \\wsl$\...)
|
||
fn is_wsl_path(path: &str) -> bool {
|
||
path.starts_with("\\\\wsl.localhost\\") || path.starts_with("\\\\wsl$\\")
|
||
}
|
||
|
||
/// 将 WSL UNC 路径转为 Linux 绝对路径(去掉 \\wsl.localhost\Distro 前缀)
|
||
fn unc_to_linux(path: &str) -> Option<String> {
|
||
let prefix = if path.starts_with("\\\\wsl.localhost\\") {
|
||
"\\\\wsl.localhost\\"
|
||
} else if path.starts_with("\\\\wsl$\\") {
|
||
"\\\\wsl$\\"
|
||
} else {
|
||
return None;
|
||
};
|
||
let rest = &path[prefix.len()..];
|
||
let slash_idx = rest.find('\\')?;
|
||
Some("/".to_string() + &rest[slash_idx + 1..].replace('\\', "/"))
|
||
}
|
||
|
||
// ── 辅助:git 操作 ────────────────────────────────────────────────────────────
|
||
|
||
fn get_head_commit(root: &Path) -> Result<String, String> {
|
||
let path_str = root.to_string_lossy();
|
||
let out = if is_wsl_path(&path_str) {
|
||
let linux = unc_to_linux(&path_str)
|
||
.ok_or_else(|| "无法转换 WSL 路径".to_string())?;
|
||
crate::silent_cmd("wsl")
|
||
.args(["git", "-C", &linux, "rev-parse", "HEAD"])
|
||
.output()
|
||
.map_err(|e| e.to_string())?
|
||
} else {
|
||
crate::silent_cmd("git")
|
||
.args(["rev-parse", "HEAD"])
|
||
.current_dir(root)
|
||
.output()
|
||
.map_err(|e| e.to_string())?
|
||
};
|
||
|
||
if out.status.success() {
|
||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||
} else {
|
||
Err("无法获取 HEAD".to_string())
|
||
}
|
||
}
|
||
|
||
fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result<Vec<String>, String> {
|
||
let path_str = root.to_string_lossy();
|
||
let out = if is_wsl_path(&path_str) {
|
||
let linux = unc_to_linux(&path_str)
|
||
.ok_or_else(|| "无法转换 WSL 路径".to_string())?;
|
||
let cmd = if let Some(prev) = last_commit {
|
||
format!("git -C {linux} diff --name-only {prev} HEAD")
|
||
} else {
|
||
format!("git -C {linux} diff-tree --no-commit-id -r --name-only HEAD")
|
||
};
|
||
crate::silent_cmd("wsl")
|
||
.args(["bash", "-c", &cmd])
|
||
.output()
|
||
.map_err(|e| e.to_string())?
|
||
} else {
|
||
let args: Vec<&str> = if let Some(prev) = last_commit {
|
||
vec!["diff", "--name-only", prev, "HEAD"]
|
||
} else {
|
||
vec!["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"]
|
||
};
|
||
crate::silent_cmd("git")
|
||
.args(&args)
|
||
.current_dir(root)
|
||
.output()
|
||
.map_err(|e| e.to_string())?
|
||
};
|
||
|
||
if out.status.success() {
|
||
Ok(String::from_utf8_lossy(&out.stdout)
|
||
.lines()
|
||
.map(|s| s.trim().to_string())
|
||
.filter(|s| !s.is_empty())
|
||
.collect())
|
||
} else {
|
||
Ok(vec![])
|
||
}
|
||
}
|
||
|
||
// ── 辅助:新增 commit 解析(飞轮 L0)─────────────────────────────────────────
|
||
|
||
/// 首次接入时只解析最近这么多条 commit,避免一次性解析整个历史。
|
||
const FIRST_TIME_LIMIT: usize = 50;
|
||
|
||
/// 获取 last_commit..HEAD 之间的新 commit(含 Rules-Applied trailer)。
|
||
/// 首次接入(last_commit 为 None)只取最近 FIRST_TIME_LIMIT 条。
|
||
/// 取数统一走 commit_metrics::read_git_log(单一 format 来源,trailer 不再丢失)。
|
||
/// WSL 路径分支已随 2026-07-04「放弃 WSL 路径项目」裁决清理。
|
||
fn get_new_commits(
|
||
root: &Path,
|
||
last_commit: Option<&str>,
|
||
) -> Vec<crate::commands::commit_metrics::GitLogEntry> {
|
||
use crate::commands::commit_metrics::{read_git_log, GitLogRange};
|
||
let range = match last_commit {
|
||
Some(prev) => GitLogRange::Since(prev),
|
||
None => GitLogRange::Limit(FIRST_TIME_LIMIT),
|
||
};
|
||
read_git_log(root, range).unwrap_or_default()
|
||
}
|
||
|
||
// ── 辅助:任务状态更新 ────────────────────────────────────────────────────────
|
||
|
||
fn update_task_statuses(root: &Path, changed_files: &[String]) -> Result<usize, String> {
|
||
let modules_dir = root.join(".blueprint").join("modules");
|
||
if !modules_dir.exists() {
|
||
return Ok(0);
|
||
}
|
||
|
||
let mut total = 0;
|
||
for entry in std::fs::read_dir(&modules_dir)
|
||
.map_err(|e| e.to_string())?
|
||
.flatten()
|
||
{
|
||
let path = entry.path();
|
||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||
continue;
|
||
}
|
||
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||
let (new_content, count) = mark_tasks_in_progress(&content, changed_files);
|
||
if count > 0 {
|
||
std::fs::write(&path, new_content).map_err(|e| e.to_string())?;
|
||
total += count;
|
||
}
|
||
}
|
||
Ok(total)
|
||
}
|
||
|
||
/// 扫描 markdown 内容,将 files 字段命中 changed_files 的 📋 todo 任务标记为 🔵 in_progress
|
||
fn mark_tasks_in_progress(content: &str, changed_files: &[String]) -> (String, usize) {
|
||
let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
|
||
let mut updated = 0;
|
||
let mut i = 0;
|
||
|
||
while i < lines.len() {
|
||
if !lines[i].starts_with("### 📋 ") {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
// 扫描该任务卡的属性行(到下一个 ## 或 ### 为止,最多 20 行)
|
||
let task_head = i;
|
||
let mut files_value: Option<String> = None;
|
||
let mut status_idx: Option<usize> = None;
|
||
|
||
let end = (i + 1 + 20).min(lines.len());
|
||
for j in (i + 1)..end {
|
||
if j > i + 1
|
||
&& (lines[j].starts_with("### ") || lines[j].starts_with("## "))
|
||
{
|
||
break;
|
||
}
|
||
let t = lines[j].trim().trim_start_matches("- ");
|
||
if let Some(v) = t.strip_prefix("files: ") {
|
||
files_value = Some(v.to_string());
|
||
}
|
||
if t.starts_with("status: ") {
|
||
status_idx = Some(j);
|
||
}
|
||
}
|
||
|
||
if let Some(fv) = files_value {
|
||
let task_files: Vec<String> = fv
|
||
.split(',')
|
||
.map(|s| normalize_path(s.trim()))
|
||
.collect();
|
||
|
||
let hit = changed_files.iter().any(|cf| {
|
||
let ncf = normalize_path(cf);
|
||
task_files
|
||
.iter()
|
||
.any(|tf| tf == &ncf || ncf.ends_with(tf.as_str()) || tf.ends_with(ncf.as_str()))
|
||
});
|
||
|
||
if hit {
|
||
// 替换标题前缀
|
||
lines[task_head] = lines[task_head].replacen("### 📋 ", "### 🔵 ", 1);
|
||
// 替换 status 行
|
||
if let Some(si) = status_idx {
|
||
lines[si] = lines[si].replace("status: todo", "status: in_progress");
|
||
}
|
||
updated += 1;
|
||
}
|
||
}
|
||
|
||
i += 1;
|
||
}
|
||
|
||
let mut result = lines.join("\n");
|
||
if content.ends_with('\n') {
|
||
result.push('\n');
|
||
}
|
||
(result, updated)
|
||
}
|
||
|
||
fn normalize_path(p: &str) -> String {
|
||
p.trim_start_matches("./").replace('\\', "/").to_string()
|
||
}
|
||
|
||
// ── 内部辅助 ──────────────────────────────────────────────────────────────────
|
||
|
||
fn list_active_paths() -> Result<Vec<String>, String> {
|
||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn
|
||
.prepare("SELECT project_path FROM blueprint_buffs WHERE status = 'active'")
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let paths = stmt
|
||
.query_map([], |row| row.get(0))
|
||
.map_err(|e| e.to_string())?
|
||
.filter_map(|r| r.ok())
|
||
.collect();
|
||
Ok(paths)
|
||
}
|
||
|
||
fn query_buff(
|
||
conn: &rusqlite::Connection,
|
||
path: &str,
|
||
) -> Result<BuffStatus, String> {
|
||
conn.query_row(
|
||
"SELECT project_path, project_id, applied_at, last_sync_at, last_commit, status
|
||
FROM blueprint_buffs WHERE project_path = ?1",
|
||
rusqlite::params![path],
|
||
|row| {
|
||
Ok(BuffStatus {
|
||
project_path: row.get(0)?,
|
||
project_id: row.get(1)?,
|
||
applied_at: row.get(2)?,
|
||
last_sync_at: row.get(3)?,
|
||
last_commit: row.get(4)?,
|
||
status: row.get(5)?,
|
||
})
|
||
},
|
||
)
|
||
.map_err(|e| e.to_string())
|
||
}
|