dev-manager-tauri/src-tauri/src/commands/buff.rs
lanrtop 08e8d6d719 fix(flywheel): 修复 Rules-Applied trailer 死链,git log 原生提取 + UPSERT 回填
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
2026-07-04 14:04:20 +09:00

440 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())
}