feat(flywheel): 阶段三执行质量预测层(trailer 死链修复 + 风险画像) #2
@ -159,8 +159,9 @@
|
||||
- acceptance: "[cargo test] read_git_log 用 %(trailers:key=Rules-Applied,valueonly) 提取 trailer,record_commit 改 UPSERT(仅补 rules_applied IS NULL 的行,不覆盖已有值)单测覆盖;[Tauri command] 对炼境自身重跑 ingest_full_git_history → commit_metrics 中 rules_applied 非空行 ≥10,rule_hit_stats 返回非空"
|
||||
- notes: buff.rs 与 commit_metrics.rs 两处重复的 FMT 常量统一为 commit_metrics::read_git_log 单一来源;git 原生 trailer 解析保持单行一条记录,避免 %B 多行解析
|
||||
|
||||
### 📋 P3-B. commit_files 采集(增量 + 全量)
|
||||
- status: todo
|
||||
### ✅ P3-B. commit_files 采集(增量 + 全量)
|
||||
- status: done
|
||||
- done_note: 2026-07-04 完成。commit_files(sha,path 联合主键 + path 索引) migration 到位;read_git_log 加 --name-only,单次 git 调用同时取指标与文件(parse_git_log_output 纯函数解析 header/文件行混排);record_commit_entry 落库文件(INSERT OR IGNORE 幂等),buff 增量与 ingest 全量两条链路自动覆盖。83 测试全绿(真仓库 e2e 含文件断言)
|
||||
- complexity: M
|
||||
- depends: flywheel-intelligence(卡 P3-A,共用 read_git_log 改造)
|
||||
- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/db.rs
|
||||
|
||||
@ -157,6 +157,7 @@ pub fn record_commit(
|
||||
}
|
||||
|
||||
/// 写入一条来自 `read_git_log` 的记录(subject 与 trailer 分列,trailer 由 git 原生解析)。
|
||||
/// 同时把触碰文件写入 commit_files(INSERT OR IGNORE 幂等,供文件级风险画像)。
|
||||
pub fn record_commit_entry(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
@ -169,7 +170,15 @@ pub fn record_commit_entry(
|
||||
} else {
|
||||
Some(entry.committed_at.as_str())
|
||||
};
|
||||
upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date)
|
||||
let changed = upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date)?;
|
||||
for path in &entry.files {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO commit_files (sha, path) VALUES (?1, ?2)",
|
||||
rusqlite::params![entry.sha, path],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 单条规则的命中统计(跨项目聚合)
|
||||
@ -296,12 +305,14 @@ pub struct IngestResult {
|
||||
/// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值
|
||||
/// (如 "R01,R06"),无 trailer 时为空串——trailer 在 commit body 里,`%s` 取不到,
|
||||
/// 必须靠 `%(trailers:...)` 提取(曾因只用 %s 导致 R1 管道死链)。
|
||||
/// `files` 来自 --name-only(P3-B 文件级风险画像数据源),merge commit 为空。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitLogEntry {
|
||||
pub sha: String,
|
||||
pub subject: String,
|
||||
pub committed_at: String,
|
||||
pub rules_trailer: String,
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
/// git log 取数范围:最近 N 条(全量/首次),或 last..HEAD(增量 watcher)。
|
||||
@ -310,27 +321,43 @@ pub enum GitLogRange<'a> {
|
||||
Since(&'a str),
|
||||
}
|
||||
|
||||
/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值(逗号分隔),每条一行。
|
||||
/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值(逗号分隔)。
|
||||
/// 配合 --name-only:header 行后跟随该 commit 触碰的文件路径行。
|
||||
const GIT_LOG_FMT: &str =
|
||||
"--format=%H%x1f%s%x1f%cI%x1f%(trailers:key=Rules-Applied,valueonly,separator=%x2C)";
|
||||
|
||||
/// 解析 GIT_LOG_FMT 输出的一行。纯函数,可独立单测。
|
||||
pub fn parse_git_log_line(line: &str) -> Option<GitLogEntry> {
|
||||
/// 解析 GIT_LOG_FMT + --name-only 的完整输出。纯函数,可独立单测。
|
||||
/// 规则:含 \x1f 的行是 commit header;其后的非空行是该 commit 的文件路径。
|
||||
pub fn parse_git_log_output(output: &str) -> Vec<GitLogEntry> {
|
||||
let mut entries: Vec<GitLogEntry> = Vec::new();
|
||||
for line in output.lines() {
|
||||
if line.contains('\u{1f}') {
|
||||
let mut parts = line.split('\u{1f}');
|
||||
let sha = parts.next()?.trim().to_string();
|
||||
let sha = parts.next().unwrap_or("").trim().to_string();
|
||||
if sha.is_empty() {
|
||||
return None;
|
||||
continue;
|
||||
}
|
||||
Some(GitLogEntry {
|
||||
entries.push(GitLogEntry {
|
||||
sha,
|
||||
subject: parts.next().unwrap_or("").to_string(),
|
||||
committed_at: parts.next().unwrap_or("").to_string(),
|
||||
rules_trailer: parts.next().unwrap_or("").trim().to_string(),
|
||||
})
|
||||
files: Vec::new(),
|
||||
});
|
||||
} else {
|
||||
let path = line.trim();
|
||||
if !path.is_empty() {
|
||||
if let Some(cur) = entries.last_mut() {
|
||||
cur.files.push(path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// 读取 git log(含 Rules-Applied trailer)。增量与全量两条链路的单一取数入口,
|
||||
/// buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。
|
||||
/// 读取 git log(含 Rules-Applied trailer 与触碰文件)。增量与全量两条链路的
|
||||
/// 单一取数入口,buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。
|
||||
pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result<Vec<GitLogEntry>, String> {
|
||||
let mut cmd = crate::silent_cmd("git");
|
||||
cmd.arg("log");
|
||||
@ -343,17 +370,14 @@ pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result<Vec<Gi
|
||||
}
|
||||
}
|
||||
let out = cmd
|
||||
.arg(GIT_LOG_FMT)
|
||||
.args([GIT_LOG_FMT, "--name-only"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.map_err(|e| format!("git log 失败: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("git log 错误: {}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(parse_git_log_line)
|
||||
.collect())
|
||||
Ok(parse_git_log_output(&String::from_utf8_lossy(&out.stdout)))
|
||||
}
|
||||
|
||||
/// 全量历史导入:扫描最近 500 条 commit,幂等写入 commit_metrics 表。
|
||||
@ -707,6 +731,7 @@ mod tests {
|
||||
subject: subject.into(),
|
||||
committed_at: "2026-07-04T00:00:00+09:00".into(),
|
||||
rules_trailer: trailer.into(),
|
||||
files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -720,17 +745,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_git_log_line_with_and_without_trailer() {
|
||||
let e = parse_git_log_line("abc\u{1f}feat(x): y\u{1f}2026-07-04T00:00:00+09:00\u{1f}R01,R06")
|
||||
.unwrap();
|
||||
assert_eq!(e.sha, "abc");
|
||||
assert_eq!(e.subject, "feat(x): y");
|
||||
assert_eq!(e.rules_trailer, "R01,R06");
|
||||
fn parse_git_log_output_headers_trailers_and_files() {
|
||||
// 两条 commit:首条带 trailer + 2 个文件,次条无 trailer + 1 个文件(--name-only 真实形状)
|
||||
let output = "abc\u{1f}feat(x): y\u{1f}2026-07-04T00:00:00+09:00\u{1f}R01,R06\n\
|
||||
\n\
|
||||
src/a.rs\n\
|
||||
src/b.rs\n\
|
||||
def\u{1f}fix: z\u{1f}2026-07-04T00:00:00+09:00\u{1f}\n\
|
||||
\n\
|
||||
src/c.rs\n";
|
||||
let entries = parse_git_log_output(output);
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].sha, "abc");
|
||||
assert_eq!(entries[0].subject, "feat(x): y");
|
||||
assert_eq!(entries[0].rules_trailer, "R01,R06");
|
||||
assert_eq!(entries[0].files, vec!["src/a.rs", "src/b.rs"]);
|
||||
assert_eq!(entries[1].rules_trailer, "", "无 trailer 时为空串");
|
||||
assert_eq!(entries[1].files, vec!["src/c.rs"]);
|
||||
|
||||
let e2 = parse_git_log_line("def\u{1f}fix: z\u{1f}2026-07-04T00:00:00+09:00\u{1f}").unwrap();
|
||||
assert_eq!(e2.rules_trailer, "", "无 trailer 时为空串");
|
||||
|
||||
assert!(parse_git_log_line("").is_none());
|
||||
assert!(parse_git_log_output("").is_empty());
|
||||
// 空 commit(merge / --allow-empty):header 后无文件行
|
||||
let no_files = parse_git_log_output("abc\u{1f}chore: m\u{1f}d\u{1f}\n");
|
||||
assert!(no_files[0].files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -785,7 +821,10 @@ mod tests {
|
||||
git(&["init", "-q"]);
|
||||
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q",
|
||||
"-m", "feat(a): no trailer"]);
|
||||
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q",
|
||||
std::fs::write(dir.join("x.rs"), "// x").unwrap();
|
||||
std::fs::write(dir.join("y.rs"), "// y").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q",
|
||||
"-m", "fix(b): with trailer", "-m", "Rules-Applied: R01,R06"]);
|
||||
|
||||
let entries = read_git_log(&dir, GitLogRange::Limit(10)).unwrap();
|
||||
@ -795,7 +834,23 @@ mod tests {
|
||||
// git log 逆序:最新在前
|
||||
assert_eq!(entries[0].subject, "fix(b): with trailer");
|
||||
assert_eq!(entries[0].rules_trailer, "R01,R06", "trailer 必须被提取(%s 死链回归)");
|
||||
assert_eq!(entries[0].files, vec!["x.rs", "y.rs"], "--name-only 文件必须被采集");
|
||||
assert_eq!(entries[1].rules_trailer, "");
|
||||
assert!(entries[1].files.is_empty(), "空 commit 无文件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_files_written_and_idempotent() {
|
||||
let conn = crate::db::conn_with_schema();
|
||||
let mut e = entry("s1", "feat(a): x", "");
|
||||
e.files = vec!["src/a.rs".into(), "src/b.rs".into()];
|
||||
record_commit_entry(&conn, "proj", &e).unwrap();
|
||||
record_commit_entry(&conn, "proj", &e).unwrap(); // 重跑
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM commit_files WHERE sha = 's1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 2, "文件写入且重跑幂等");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -538,6 +538,16 @@ fn migrate(conn: &rusqlite::Connection) {
|
||||
// 增量迁移(飞轮 R1):Rules-Applied trailer 规则命中列(已存在则忽略)
|
||||
let _ = conn.execute("ALTER TABLE commit_metrics ADD COLUMN rules_applied TEXT", []);
|
||||
|
||||
// 飞轮阶段三(P3-B):commit 触碰的文件清单,供文件级返工风险画像
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS commit_files (
|
||||
sha TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY (sha, path)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_commit_files_path ON commit_files(path);
|
||||
").expect("create commit_files table");
|
||||
|
||||
// 飞轮 L1:PR 生命周期事件(opened / closed / merged)
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS pr_events (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user