feat(flywheel): commit_files 采集——文件级风险画像数据地基
- 新表 commit_files(sha,path 联合主键 + path 索引),migration 入 migrate() - read_git_log 加 --name-only:单次 git 调用同时取 conventional 指标、 Rules-Applied trailer 与触碰文件,零额外进程开销 - parse_git_log_output 纯函数解析 header/文件行混排输出(含 \x1f 的行 是 header,其后非空行归属该 commit) - record_commit_entry 落库文件清单(INSERT OR IGNORE 幂等),buff 增量 与 ingest 全量两条链路自动覆盖,无需各自改动 Rules-Applied: R05,R06,R07
This commit is contained in:
parent
08e8d6d719
commit
0ac809970f
@ -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 返回非空"
|
- 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 多行解析
|
- notes: buff.rs 与 commit_metrics.rs 两处重复的 FMT 常量统一为 commit_metrics::read_git_log 单一来源;git 原生 trailer 解析保持单行一条记录,避免 %B 多行解析
|
||||||
|
|
||||||
### 📋 P3-B. commit_files 采集(增量 + 全量)
|
### ✅ P3-B. commit_files 采集(增量 + 全量)
|
||||||
- status: todo
|
- 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
|
- complexity: M
|
||||||
- depends: flywheel-intelligence(卡 P3-A,共用 read_git_log 改造)
|
- 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
|
- 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 原生解析)。
|
/// 写入一条来自 `read_git_log` 的记录(subject 与 trailer 分列,trailer 由 git 原生解析)。
|
||||||
|
/// 同时把触碰文件写入 commit_files(INSERT OR IGNORE 幂等,供文件级风险画像)。
|
||||||
pub fn record_commit_entry(
|
pub fn record_commit_entry(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
@ -169,7 +170,15 @@ pub fn record_commit_entry(
|
|||||||
} else {
|
} else {
|
||||||
Some(entry.committed_at.as_str())
|
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 值
|
/// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值
|
||||||
/// (如 "R01,R06"),无 trailer 时为空串——trailer 在 commit body 里,`%s` 取不到,
|
/// (如 "R01,R06"),无 trailer 时为空串——trailer 在 commit body 里,`%s` 取不到,
|
||||||
/// 必须靠 `%(trailers:...)` 提取(曾因只用 %s 导致 R1 管道死链)。
|
/// 必须靠 `%(trailers:...)` 提取(曾因只用 %s 导致 R1 管道死链)。
|
||||||
|
/// `files` 来自 --name-only(P3-B 文件级风险画像数据源),merge commit 为空。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct GitLogEntry {
|
pub struct GitLogEntry {
|
||||||
pub sha: String,
|
pub sha: String,
|
||||||
pub subject: String,
|
pub subject: String,
|
||||||
pub committed_at: String,
|
pub committed_at: String,
|
||||||
pub rules_trailer: String,
|
pub rules_trailer: String,
|
||||||
|
pub files: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// git log 取数范围:最近 N 条(全量/首次),或 last..HEAD(增量 watcher)。
|
/// git log 取数范围:最近 N 条(全量/首次),或 last..HEAD(增量 watcher)。
|
||||||
@ -310,27 +321,43 @@ pub enum GitLogRange<'a> {
|
|||||||
Since(&'a str),
|
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 =
|
const GIT_LOG_FMT: &str =
|
||||||
"--format=%H%x1f%s%x1f%cI%x1f%(trailers:key=Rules-Applied,valueonly,separator=%x2C)";
|
"--format=%H%x1f%s%x1f%cI%x1f%(trailers:key=Rules-Applied,valueonly,separator=%x2C)";
|
||||||
|
|
||||||
/// 解析 GIT_LOG_FMT 输出的一行。纯函数,可独立单测。
|
/// 解析 GIT_LOG_FMT + --name-only 的完整输出。纯函数,可独立单测。
|
||||||
pub fn parse_git_log_line(line: &str) -> Option<GitLogEntry> {
|
/// 规则:含 \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 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() {
|
if sha.is_empty() {
|
||||||
return None;
|
continue;
|
||||||
}
|
}
|
||||||
Some(GitLogEntry {
|
entries.push(GitLogEntry {
|
||||||
sha,
|
sha,
|
||||||
subject: parts.next().unwrap_or("").to_string(),
|
subject: parts.next().unwrap_or("").to_string(),
|
||||||
committed_at: parts.next().unwrap_or("").to_string(),
|
committed_at: parts.next().unwrap_or("").to_string(),
|
||||||
rules_trailer: parts.next().unwrap_or("").trim().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)。增量与全量两条链路的单一取数入口,
|
/// 读取 git log(含 Rules-Applied trailer 与触碰文件)。增量与全量两条链路的
|
||||||
/// buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。
|
/// 单一取数入口,buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。
|
||||||
pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result<Vec<GitLogEntry>, String> {
|
pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result<Vec<GitLogEntry>, String> {
|
||||||
let mut cmd = crate::silent_cmd("git");
|
let mut cmd = crate::silent_cmd("git");
|
||||||
cmd.arg("log");
|
cmd.arg("log");
|
||||||
@ -343,17 +370,14 @@ pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result<Vec<Gi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let out = cmd
|
let out = cmd
|
||||||
.arg(GIT_LOG_FMT)
|
.args([GIT_LOG_FMT, "--name-only"])
|
||||||
.current_dir(root)
|
.current_dir(root)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("git log 失败: {e}"))?;
|
.map_err(|e| format!("git log 失败: {e}"))?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return Err(format!("git log 错误: {}", String::from_utf8_lossy(&out.stderr)));
|
return Err(format!("git log 错误: {}", String::from_utf8_lossy(&out.stderr)));
|
||||||
}
|
}
|
||||||
Ok(String::from_utf8_lossy(&out.stdout)
|
Ok(parse_git_log_output(&String::from_utf8_lossy(&out.stdout)))
|
||||||
.lines()
|
|
||||||
.filter_map(parse_git_log_line)
|
|
||||||
.collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 全量历史导入:扫描最近 500 条 commit,幂等写入 commit_metrics 表。
|
/// 全量历史导入:扫描最近 500 条 commit,幂等写入 commit_metrics 表。
|
||||||
@ -707,6 +731,7 @@ mod tests {
|
|||||||
subject: subject.into(),
|
subject: subject.into(),
|
||||||
committed_at: "2026-07-04T00:00:00+09:00".into(),
|
committed_at: "2026-07-04T00:00:00+09:00".into(),
|
||||||
rules_trailer: trailer.into(),
|
rules_trailer: trailer.into(),
|
||||||
|
files: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -720,17 +745,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_git_log_line_with_and_without_trailer() {
|
fn parse_git_log_output_headers_trailers_and_files() {
|
||||||
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")
|
// 两条 commit:首条带 trailer + 2 个文件,次条无 trailer + 1 个文件(--name-only 真实形状)
|
||||||
.unwrap();
|
let output = "abc\u{1f}feat(x): y\u{1f}2026-07-04T00:00:00+09:00\u{1f}R01,R06\n\
|
||||||
assert_eq!(e.sha, "abc");
|
\n\
|
||||||
assert_eq!(e.subject, "feat(x): y");
|
src/a.rs\n\
|
||||||
assert_eq!(e.rules_trailer, "R01,R06");
|
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!(parse_git_log_output("").is_empty());
|
||||||
assert_eq!(e2.rules_trailer, "", "无 trailer 时为空串");
|
// 空 commit(merge / --allow-empty):header 后无文件行
|
||||||
|
let no_files = parse_git_log_output("abc\u{1f}chore: m\u{1f}d\u{1f}\n");
|
||||||
assert!(parse_git_log_line("").is_none());
|
assert!(no_files[0].files.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -785,7 +821,10 @@ mod tests {
|
|||||||
git(&["init", "-q"]);
|
git(&["init", "-q"]);
|
||||||
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q",
|
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q",
|
||||||
"-m", "feat(a): no trailer"]);
|
"-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"]);
|
"-m", "fix(b): with trailer", "-m", "Rules-Applied: R01,R06"]);
|
||||||
|
|
||||||
let entries = read_git_log(&dir, GitLogRange::Limit(10)).unwrap();
|
let entries = read_git_log(&dir, GitLogRange::Limit(10)).unwrap();
|
||||||
@ -795,7 +834,23 @@ mod tests {
|
|||||||
// git log 逆序:最新在前
|
// git log 逆序:最新在前
|
||||||
assert_eq!(entries[0].subject, "fix(b): with trailer");
|
assert_eq!(entries[0].subject, "fix(b): with trailer");
|
||||||
assert_eq!(entries[0].rules_trailer, "R01,R06", "trailer 必须被提取(%s 死链回归)");
|
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_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]
|
#[test]
|
||||||
|
|||||||
@ -538,6 +538,16 @@ fn migrate(conn: &rusqlite::Connection) {
|
|||||||
// 增量迁移(飞轮 R1):Rules-Applied trailer 规则命中列(已存在则忽略)
|
// 增量迁移(飞轮 R1):Rules-Applied trailer 规则命中列(已存在则忽略)
|
||||||
let _ = conn.execute("ALTER TABLE commit_metrics ADD COLUMN rules_applied TEXT", []);
|
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)
|
// 飞轮 L1:PR 生命周期事件(opened / closed / merged)
|
||||||
conn.execute_batch("
|
conn.execute_batch("
|
||||||
CREATE TABLE IF NOT EXISTS pr_events (
|
CREATE TABLE IF NOT EXISTS pr_events (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user