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
This commit is contained in:
lanrtop 2026-07-04 14:04:20 +09:00
parent 05d7673990
commit 08e8d6d719
4 changed files with 282 additions and 128 deletions

View File

@ -306,7 +306,7 @@ modules:
area: backend area: backend
status: in_progress status: in_progress
progress: 92 progress: 92
note: "L0+F1+F2+R1+R2 完成2026-07-02rule_effectiveness 死链已接通);阶段三待数据覆盖 ≥10 项目" note: "阶段三已过 architect+splitter2026-07-04P3-A~E 五卡待执行P0 发现 R1 trailer 管道死链(%s 只取 subjectP3-A 修复"
position: [1750, 650] position: [1750, 650]
- id: cloud-products - id: cloud-products

View File

@ -144,20 +144,48 @@
> 背景:"失败定边界、经验细化为变体"是经验飞轮的因果自洽核心,当前规则库只有正向 scope无反例结构。 > 背景:"失败定边界、经验细化为变体"是经验飞轮的因果自洽核心,当前规则库只有正向 scope无反例结构。
> 完成记录2026-07-02CONVENTIONS 升 v1.9.0,新增 boundaries/failure_cases 字段说明 + R11 + abandoned 模块状态定义向其他项目的分发随下次发版生效CONVENTIONS 编译进二进制)。 > 完成记录2026-07-02CONVENTIONS 升 v1.9.0,新增 boundaries/failure_cases 字段说明 + R11 + abandoned 模块状态定义向其他项目的分发随下次发版生效CONVENTIONS 编译进二进制)。
### 阶段三:执行质量预测层 ### 阶段三:执行质量预测层(已过 /architect + /splitter2026-07-04
> 触发条件已达标commit_metrics 覆盖 17 项目 / 735 commit459 conventional
> 设计裁决:规则置信度闭环 + 风险预警本期做;时长分布(无任务时间戳)与模式库推荐(笔记进料枯竭)缓做。
> 预测消费端 = MCP 工具 `predict_task_risk`agent 开工前查询),梦核数据段为辅——预测给干活的 agent 用,不是给看板前的人看。
> **P0 侦察发现**R1 trailer 管道是死链——git log 用 `%s` 只取 subjectRules-Applied trailer 在 body 里永远读不到(炼境自身 11 条带 trailer 的 commitDB rules_applied 全空单测喂全文所以全绿。P3-A 修复。
### ✅ P3-A. trailer 链路修复 + UPSERT 回填
- status: done
- done_note: 2026-07-04 完成。GIT_LOG_FMT 加 %(trailers:key=Rules-Applied,valueonly)git 原生 trailer 解析保持单行记录record_commit_entry + upsert_metricsON CONFLICT 仅回填 NULL绝不覆盖buff.rs get_new_commits 委托 commit_metrics::read_git_log消灭重复 FMT + 顺手清理该函数 WSL 分支ingest_full_git_history 改走 record_commit_entry消灭第三处重复 INSERT。测试真 git 仓库 e2e trailer 提取回归 + UPSERT 四态语义82 测试全绿。存量回填待新 build 重启后跑全量导入终验
- complexity: M
- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs
- acceptance: "[cargo test] read_git_log 用 %(trailers:key=Rules-Applied,valueonly) 提取 trailerrecord_commit 改 UPSERT仅补 rules_applied IS NULL 的行,不覆盖已有值)单测覆盖;[Tauri command] 对炼境自身重跑 ingest_full_git_history → commit_metrics 中 rules_applied 非空行 ≥10rule_hit_stats 返回非空"
- notes: buff.rs 与 commit_metrics.rs 两处重复的 FMT 常量统一为 commit_metrics::read_git_log 单一来源git 原生 trailer 解析保持单行一条记录,避免 %B 多行解析
### 📋 P3-B. commit_files 采集(增量 + 全量)
- status: todo - status: todo
- complexity: L - complexity: M
- depends: flywheel-intelligence阶段二完成且数据积累达标后启动 - depends: flywheel-intelligence卡 P3-A共用 read_git_log 改造
- files: TBD需 /architect 后确定) - files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/db.rs
- acceptance: TBD - acceptance: "[cargo test] 新表 commit_files(sha,path 联合主键) migration 到位R05git log --name-only 解析纯函数单测覆盖多 commit 多文件;[Tauri command] ingest_full_git_history 对炼境自身重跑 → commit_files 行数 >0 且重跑幂等(行数不变)"
阶段三方向(待 /architect 设计): ### 📋 P3-C. risk.rs 风险画像 + predict_task_risk MCP 工具
- 任务完成时长分布(为 S/M/L 定义时间基准) - status: todo
- 高风险任务预警(特定文件组合 → 历史高返工率) - complexity: M
- 跨项目模式库推荐 - depends: flywheel-intelligence卡 P3-B
- **自动推荐规则置信度更新**:梦核读取复盘笔记中的 `auto_applied_rules` + `rule_effectiveness` 字段,输出 CONVENTIONS 规则库的 confidence/evidence_count 更新建议;新增规则提案(从高频踩坑模式自动归纳) - files: src-tauri/src/commands/risk.rs, src-tauri/src/commands/mod.rs, src-tauri/src/mcp_tools.rs
- acceptance: "[MCP tool] predict_task_risk(project_id, scope 或 files) → JSON 含 risk_level/rework_rate/sample_size/confidence/hot_files无 error 字段sample_size<5 confidence=low[cargo test] 风险聚合纯函数conn 注入 + in-memory DB覆盖高返工 scope小样本脏历史conventional 率低标不可信三种输入tools_list_result predict_task_risk 断言"
> ⚠️ 阶段三为 L 级复杂度,到时须先走 /architect → /splitter 流程,禁止直接写代码。 ### 📋 P3-D. 梦核 prompt 高风险 scope 数据段
- status: todo
- complexity: S
- depends: flywheel-intelligence卡 P3-C
- files: src-tauri/src/commands/blueprint.rs
- acceptance: "[cargo test] 梦核 prompt 构建函数单测断言输出含「跨项目高风险 scope」数据段返工率 top scope + 项目分布);规则置信度段在 P3-A 数据流入后自然激活(注入点 blueprint.rs rule_hit_stats 已存在)"
### 📋 P3-E. FlywheelPanel 风险展示 + commands.ts 同步(可选收尾)
- status: todo
- complexity: S
- depends: flywheel-intelligence卡 P3-C
- files: src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx
- acceptance: "[pnpm typecheck] exit 0FlywheelPanel「Git 执行质量」卡片新增各项目返工率 top scope 列get_scope_risk_profile 封装同步R01"
## 决策记录 ## 决策记录

View File

@ -160,14 +160,8 @@ fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> {
} }
// 批量写入用事务包裹(首次可达数十条,避免逐条 fsync // 批量写入用事务包裹(首次可达数十条,避免逐条 fsync
let tx = conn.unchecked_transaction(); let tx = conn.unchecked_transaction();
for (sha, subject, date) in &commits { for entry in &commits {
let _ = crate::commands::commit_metrics::record_commit( let _ = crate::commands::commit_metrics::record_commit_entry(&conn, &project_id, entry);
&conn,
&project_id,
sha,
subject,
Some(date.as_str()),
);
} }
if let Ok(tx) = tx { if let Ok(tx) = tx {
let _ = tx.commit(); let _ = tx.commit();
@ -292,57 +286,20 @@ fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result<Vec<Strin
/// 首次接入时只解析最近这么多条 commit避免一次性解析整个历史。 /// 首次接入时只解析最近这么多条 commit避免一次性解析整个历史。
const FIRST_TIME_LIMIT: usize = 50; const FIRST_TIME_LIMIT: usize = 50;
/// 获取 last_commit..HEAD 之间的新 commitsha, subject, committed_at ISO)。 /// 获取 last_commit..HEAD 之间的新 commit含 Rules-Applied trailer)。
/// 首次接入last_commit 为 None只取最近 FIRST_TIME_LIMIT 条。 /// 首次接入last_commit 为 None只取最近 FIRST_TIME_LIMIT 条。
fn get_new_commits(root: &Path, last_commit: Option<&str>) -> Vec<(String, String, String)> { /// 取数统一走 commit_metrics::read_git_log单一 format 来源trailer 不再丢失)。
// sha \x1f subject \x1f committer-date(ISO),每条 commit 一行 /// WSL 路径分支已随 2026-07-04「放弃 WSL 路径项目」裁决清理。
const FMT: &str = "--format=%H%x1f%s%x1f%cI"; fn get_new_commits(
let path_str = root.to_string_lossy(); root: &Path,
last_commit: Option<&str>,
let out = if is_wsl_path(&path_str) { ) -> Vec<crate::commands::commit_metrics::GitLogEntry> {
let linux = match unc_to_linux(&path_str) { use crate::commands::commit_metrics::{read_git_log, GitLogRange};
Some(l) => l, let range = match last_commit {
None => return vec![], Some(prev) => GitLogRange::Since(prev),
}; None => GitLogRange::Limit(FIRST_TIME_LIMIT),
let range = match last_commit {
Some(prev) => format!("{prev}..HEAD"),
None => format!("-n {FIRST_TIME_LIMIT}"),
};
let cmd = format!("git -C {linux} log {range} {FMT}");
crate::silent_cmd("wsl").args(["bash", "-c", &cmd]).output()
} else {
let mut cmd = crate::silent_cmd("git");
cmd.arg("log");
match last_commit {
Some(prev) => {
cmd.arg(format!("{prev}..HEAD"));
}
None => {
cmd.arg("-n").arg(FIRST_TIME_LIMIT.to_string());
}
}
cmd.arg(FMT);
cmd.current_dir(root).output()
}; };
read_git_log(root, range).unwrap_or_default()
let out = match out {
Ok(o) if o.status.success() => o,
_ => return vec![],
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
let mut parts = line.split('\u{1f}');
let sha = parts.next()?.trim().to_string();
if sha.is_empty() {
return None;
}
let subject = parts.next().unwrap_or("").to_string();
let date = parts.next().unwrap_or("").to_string();
Some((sha, subject, date))
})
.collect()
} }
// ── 辅助:任务状态更新 ──────────────────────────────────────────────────────── // ── 辅助:任务状态更新 ────────────────────────────────────────────────────────

View File

@ -75,6 +75,21 @@ fn non_conventional(is_ci_auto: bool) -> ParsedCommit {
} }
} }
/// 把 trailer 原始值(如 "R01, r06 R01")归一化为大写去重逗号串。
/// 只接受 R+数字 形式的 token无有效 token 返回 None。
pub fn normalize_rule_tokens(raw: &str) -> Option<String> {
let mut ids: Vec<String> = raw
.split([',', ' ', ''])
.map(|t| t.trim().to_uppercase())
.filter(|t| {
t.len() >= 2 && t.starts_with('R') && t[1..].chars().all(|c| c.is_ascii_digit())
})
.collect();
ids.sort();
ids.dedup();
if ids.is_empty() { None } else { Some(ids.join(",")) }
}
/// 解析 commit message 中的 `Rules-Applied: R01,R06` trailer飞轮 R1 规则有效性数据源)。 /// 解析 commit message 中的 `Rules-Applied: R01,R06` trailer飞轮 R1 规则有效性数据源)。
/// 只接受 R+数字 形式的 token规范化为大写去重逗号串无 trailer 或无有效 token 返回 None。 /// 只接受 R+数字 形式的 token规范化为大写去重逗号串无 trailer 或无有效 token 返回 None。
pub fn parse_rules_applied(message: &str) -> Option<String> { pub fn parse_rules_applied(message: &str) -> Option<String> {
@ -87,24 +102,47 @@ pub fn parse_rules_applied(message: &str) -> Option<String> {
else { else {
continue; continue;
}; };
let rest = rest.trim(); return normalize_rule_tokens(rest);
let mut ids: Vec<String> = rest
.split([',', ' ', ''])
.map(|t| t.trim().to_uppercase())
.filter(|t| {
t.len() >= 2
&& t.starts_with('R')
&& t[1..].chars().all(|c| c.is_ascii_digit())
})
.collect();
ids.sort();
ids.dedup();
return if ids.is_empty() { None } else { Some(ids.join(",")) };
} }
None None
} }
/// 解析并写入一条 commit 记录sha 为主键,重复写入忽略)。 /// UPSERT 写入一条 commit 记录:新 sha 插入;已存在且 rules_applied 为空时仅回填
/// rules_applied绝不覆盖已有值——回填幂等的关键约束。返回是否产生了写入。
fn upsert_metrics(
conn: &Connection,
project_id: &str,
sha: &str,
parsed: &ParsedCommit,
rules_applied: Option<&str>,
committed_at: Option<&str>,
) -> Result<bool, String> {
let n = conn
.execute(
"INSERT INTO commit_metrics
(sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at, rules_applied)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(sha) DO UPDATE SET rules_applied = excluded.rules_applied
WHERE commit_metrics.rules_applied IS NULL
AND excluded.rules_applied IS NOT NULL",
rusqlite::params![
sha,
project_id,
parsed.commit_type,
parsed.scope,
parsed.is_rework as i32,
parsed.is_ci_auto as i32,
committed_at,
rules_applied,
],
)
.map_err(|e| e.to_string())?;
Ok(n > 0)
}
/// 解析并写入一条 commit 记录(从完整 message 提取 conventional + Rules-Applied trailer
/// 生产链路的 git log 只给 subjecttrailer 走 `record_commit_entry`;本函数保留给
/// 持有完整 message 的调用方与测试。
pub fn record_commit( pub fn record_commit(
conn: &Connection, conn: &Connection,
project_id: &str, project_id: &str,
@ -114,25 +152,26 @@ pub fn record_commit(
) -> Result<(), String> { ) -> Result<(), String> {
let p = parse_conventional(message); let p = parse_conventional(message);
let rules_applied = parse_rules_applied(message); let rules_applied = parse_rules_applied(message);
conn.execute( upsert_metrics(conn, project_id, sha, &p, rules_applied.as_deref(), committed_at)?;
"INSERT OR IGNORE INTO commit_metrics
(sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at, rules_applied)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
sha,
project_id,
p.commit_type,
p.scope,
p.is_rework as i32,
p.is_ci_auto as i32,
committed_at,
rules_applied,
],
)
.map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
/// 写入一条来自 `read_git_log` 的记录subject 与 trailer 分列trailer 由 git 原生解析)。
pub fn record_commit_entry(
conn: &Connection,
project_id: &str,
entry: &GitLogEntry,
) -> Result<bool, String> {
let p = parse_conventional(&entry.subject);
let rules = normalize_rule_tokens(&entry.rules_trailer);
let date = if entry.committed_at.is_empty() {
None
} else {
Some(entry.committed_at.as_str())
};
upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date)
}
/// 单条规则的命中统计(跨项目聚合) /// 单条规则的命中统计(跨项目聚合)
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RuleHitStat { pub struct RuleHitStat {
@ -248,17 +287,63 @@ const FULL_HISTORY_LIMIT: usize = 500;
/// 全量历史补录结果。 /// 全量历史补录结果。
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct IngestResult { pub struct IngestResult {
/// 本次新写入 commit 数 /// 本次产生写入的 commit 数(新插入 + rules_applied 回填)
pub imported: u32, pub imported: u32,
/// 已存在INSERT OR IGNORE 跳过) /// 已存在且无需回填(跳过)
pub skipped: u32, pub skipped: u32,
} }
/// 读取 git log 最近 limit 条:返回 (sha, subject, committed_at_iso)。 /// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值
fn read_git_log(root: &std::path::Path, limit: usize) -> Result<Vec<(String, String, String)>, String> { /// (如 "R01,R06"),无 trailer 时为空串——trailer 在 commit body 里,`%s` 取不到,
const FMT: &str = "--format=%H%x1f%s%x1f%cI"; /// 必须靠 `%(trailers:...)` 提取(曾因只用 %s 导致 R1 管道死链)。
let out = crate::silent_cmd("git") #[derive(Debug, Clone, PartialEq, Eq)]
.args(["log", "-n", &limit.to_string(), FMT]) pub struct GitLogEntry {
pub sha: String,
pub subject: String,
pub committed_at: String,
pub rules_trailer: String,
}
/// git log 取数范围:最近 N 条(全量/首次),或 last..HEAD增量 watcher
pub enum GitLogRange<'a> {
Limit(usize),
Since(&'a str),
}
/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值逗号分隔每条一行。
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> {
let mut parts = line.split('\u{1f}');
let sha = parts.next()?.trim().to_string();
if sha.is_empty() {
return None;
}
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(),
})
}
/// 读取 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");
match range {
GitLogRange::Limit(n) => {
cmd.args(["-n", &n.to_string()]);
}
GitLogRange::Since(prev) => {
cmd.arg(format!("{prev}..HEAD"));
}
}
let out = cmd
.arg(GIT_LOG_FMT)
.current_dir(root) .current_dir(root)
.output() .output()
.map_err(|e| format!("git log 失败: {e}"))?; .map_err(|e| format!("git log 失败: {e}"))?;
@ -267,14 +352,7 @@ fn read_git_log(root: &std::path::Path, limit: usize) -> Result<Vec<(String, Str
} }
Ok(String::from_utf8_lossy(&out.stdout) Ok(String::from_utf8_lossy(&out.stdout)
.lines() .lines()
.filter_map(|line| { .filter_map(parse_git_log_line)
let mut parts = line.split('\u{1f}');
let sha = parts.next()?.trim().to_string();
if sha.is_empty() { return None; }
let subject = parts.next().unwrap_or("").to_string();
let date = parts.next().unwrap_or("").to_string();
Some((sha, subject, date))
})
.collect()) .collect())
} }
@ -290,7 +368,7 @@ pub fn ingest_full_git_history(
if !root.exists() { if !root.exists() {
return Err(format!("目录不存在: {}", root.display())); return Err(format!("目录不存在: {}", root.display()));
} }
let commits = read_git_log(root, FULL_HISTORY_LIMIT)?; let commits = read_git_log(root, GitLogRange::Limit(FULL_HISTORY_LIMIT))?;
let total = commits.len() as u32; let total = commits.len() as u32;
let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
@ -306,19 +384,11 @@ pub fn ingest_full_git_history(
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
let mut imported = 0u32; let mut imported = 0u32;
for (sha, subject, date) in &commits { for entry in &commits {
let p = parse_conventional(subject); // 新 sha 插入;已存在的行 UPSERT 回填 rules_appliedtrailer 死链修复后的存量补数路径)
let n = conn.execute( if record_commit_entry(&conn, &effective_id, entry)? {
"INSERT OR IGNORE INTO commit_metrics imported += 1;
(sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at) }
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
sha, effective_id, p.commit_type, p.scope,
p.is_rework as i32, p.is_ci_auto as i32,
if date.is_empty() { None } else { Some(date.as_str()) },
],
).map_err(|e| e.to_string())?;
imported += n as u32;
} }
tx.commit().map_err(|e| e.to_string())?; tx.commit().map_err(|e| e.to_string())?;
Ok(IngestResult { imported, skipped: total - imported }) Ok(IngestResult { imported, skipped: total - imported })
@ -629,6 +699,105 @@ mod tests {
assert_eq!(total, 1, "同 sha 重复写入应被忽略"); assert_eq!(total, 1, "同 sha 重复写入应被忽略");
} }
// ── P3-Atrailer 链路修复 + UPSERT 回填 ─────────────────────────────────────
fn entry(sha: &str, subject: &str, trailer: &str) -> GitLogEntry {
GitLogEntry {
sha: sha.into(),
subject: subject.into(),
committed_at: "2026-07-04T00:00:00+09:00".into(),
rules_trailer: trailer.into(),
}
}
fn rules_of(conn: &rusqlite::Connection, sha: &str) -> Option<String> {
conn.query_row(
"SELECT rules_applied FROM commit_metrics WHERE sha = ?1",
[sha],
|r| r.get(0),
)
.unwrap()
}
#[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");
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());
}
#[test]
fn normalize_rule_tokens_filters_and_dedups() {
assert_eq!(normalize_rule_tokens("R01, r06 R01").as_deref(), Some("R01,R06"));
assert_eq!(normalize_rule_tokens("无效, abc"), None);
assert_eq!(normalize_rule_tokens(""), None);
}
#[test]
fn upsert_backfills_null_rules_only() {
let conn = crate::db::conn_with_schema();
// 模拟存量行:%s 时代写入rules_applied 为 NULL
record_commit(&conn, "proj", "old", "feat(a): x", None).unwrap();
assert_eq!(rules_of(&conn, "old"), None);
// 回填:同 sha 带 trailer 重写 → 补上
let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R01,R06")).unwrap();
assert!(changed, "回填应计为写入");
assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06"));
// 不覆盖:已有值的行,不同 trailer 重写 → 保持原值
let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R99")).unwrap();
assert!(!changed, "已有值不应被覆盖");
assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06"));
// 无 trailer 重写已有 NULL 行 → 不产生无意义 UPDATE
record_commit(&conn, "proj", "old2", "chore: y", None).unwrap();
let changed = record_commit_entry(&conn, "proj", &entry("old2", "chore: y", "")).unwrap();
assert!(!changed);
// 新行带 trailer 直接写入
assert!(record_commit_entry(&conn, "proj", &entry("new", "fix(b): z", "R05")).unwrap());
assert_eq!(rules_of(&conn, "new").as_deref(), Some("R05"));
}
/// 端到端:真 git 仓库验证 %(trailers) 提取——这是 R1 死链(%s 只取 subject的回归测试。
#[test]
fn read_git_log_extracts_trailer_from_real_repo() {
let dir = std::env::temp_dir().join(format!("lj-trailer-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(args)
.current_dir(&dir)
.output()
.expect("git 可执行");
assert!(out.status.success(), "git {:?} 失败: {}", args, String::from_utf8_lossy(&out.stderr));
};
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",
"-m", "fix(b): with trailer", "-m", "Rules-Applied: R01,R06"]);
let entries = read_git_log(&dir, GitLogRange::Limit(10)).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(entries.len(), 2);
// git log 逆序:最新在前
assert_eq!(entries[0].subject, "fix(b): with trailer");
assert_eq!(entries[0].rules_trailer, "R01,R06", "trailer 必须被提取(%s 死链回归)");
assert_eq!(entries[1].rules_trailer, "");
}
#[test] #[test]
fn conventional_stats_counts_ratio() { fn conventional_stats_counts_ratio() {
let conn = crate::db::conn_with_schema(); let conn = crate::db::conn_with_schema();