From 0ac809970f15910b703d2eec8d2f1fb4fcfee4f4 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 14:07:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(flywheel):=20commit=5Ffiles=20=E9=87=87?= =?UTF-8?q?=E9=9B=86=E2=80=94=E2=80=94=E6=96=87=E4=BB=B6=E7=BA=A7=E9=A3=8E?= =?UTF-8?q?=E9=99=A9=E7=94=BB=E5=83=8F=E6=95=B0=E6=8D=AE=E5=9C=B0=E5=9F=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新表 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 --- .blueprint/modules/flywheel-intelligence.md | 5 +- src-tauri/src/commands/commit_metrics.rs | 119 ++++++++++++++------ src-tauri/src/db.rs | 10 ++ 3 files changed, 100 insertions(+), 34 deletions(-) diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index 6e3fa98..5489d5b 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -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 diff --git a/src-tauri/src/commands/commit_metrics.rs b/src-tauri/src/commands/commit_metrics.rs index 4b4595e..46f37c7 100644 --- a/src-tauri/src/commands/commit_metrics.rs +++ b/src-tauri/src/commands/commit_metrics.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, } /// 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 { - let mut parts = line.split('\u{1f}'); - let sha = parts.next()?.trim().to_string(); - if sha.is_empty() { - return None; +/// 解析 GIT_LOG_FMT + --name-only 的完整输出。纯函数,可独立单测。 +/// 规则:含 \x1f 的行是 commit header;其后的非空行是该 commit 的文件路径。 +pub fn parse_git_log_output(output: &str) -> Vec { + let mut entries: Vec = Vec::new(); + for line in output.lines() { + if line.contains('\u{1f}') { + let mut parts = line.split('\u{1f}'); + let sha = parts.next().unwrap_or("").trim().to_string(); + if sha.is_empty() { + continue; + } + 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()); + } + } + } } - Some(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(), - }) + 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, 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