@ -176,12 +176,8 @@ fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> {
}
}
// 文件路径匹配 → 标记命中的任务卡 🔵
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 ;
}
// 文件路径匹配 → 兜底标记命中的任务卡 🔵(本批已显式推进 / HEAD 非前进时不推断)
let tasks_updated = infer_task_marks ( root , last_commit . as_deref ( ) , & current_commit ) ? > 0 ;
// 有新 commit 即刷新飞轮快照( rework_count 可能已变);任务卡有变动则通知前端重绘
let _ = crate ::commands ::blueprint ::get_blueprint ( project_path . to_string ( ) ) ;
@ -250,14 +246,19 @@ fn get_head_commit(root: &Path) -> Result<String, String> {
}
}
fn get_changed_files ( root : & Path , last_commit : Option < & str > ) -> Result < Vec < String > , String > {
/// head 传轮询时读到的 HEAD sha( 而非字面量 "HEAD"),保证同一批次各项判定口径一致。
fn get_changed_files (
root : & Path ,
last_commit : Option < & str > ,
head : & 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 " )
format! ( " git -C {linux} diff --name-only {prev} {head} " )
} else {
format! ( " git -C {linux} diff-tree --no-commit-id -r --name-only HEAD " )
format! ( " git -C {linux} diff-tree --no-commit-id -r --name-only {head} " )
} ;
crate ::silent_cmd ( " wsl " )
. args ( [ " bash " , " -c " , & cmd ] )
@ -265,9 +266,9 @@ fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result<Vec<Strin
. map_err ( | e | e . to_string ( ) ) ?
} else {
let args : Vec < & str > = if let Some ( prev ) = last_commit {
vec! [ " diff " , " --name-only " , prev , " HEAD " ]
vec! [ " diff " , " --name-only " , prev , head ]
} else {
vec! [ " diff-tree " , " --no-commit-id " , " -r " , " --name-only " , " HEAD " ]
vec! [ " diff-tree " , " --no-commit-id " , " -r " , " --name-only " , head ]
} ;
crate ::silent_cmd ( " git " )
. args ( & args )
@ -287,6 +288,59 @@ fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result<Vec<Strin
}
}
// 以下两个 git 辅助不再做 WSL 分支( 2026-07-04「放弃 WSL 路径项目」裁决);
// WSL 项目上二者失败 → 按「无法确认」处理,不做推断。
/// prev 是否为 head 的祖先。checkout / reset / rebase 会让 HEAD 非前进地变化,
/// 此时 prev..head 的 diff 不代表新提交。git 出错(如 prev 已不存在)记日志并按「否」处理。
fn is_ancestor ( root : & Path , prev : & str , head : & str ) -> bool {
let out = crate ::silent_cmd ( " git " )
. args ( [ " merge-base " , " --is-ancestor " , prev , head ] )
. current_dir ( root )
. output ( ) ;
match out . as_ref ( ) . map ( | o | o . status . code ( ) ) {
Ok ( Some ( 0 ) ) = > true ,
Ok ( Some ( 1 ) ) = > false ,
_ = > {
let detail = match & out {
Ok ( o ) = > String ::from_utf8_lossy ( & o . stderr ) . trim ( ) . to_string ( ) ,
Err ( e ) = > e . to_string ( ) ,
} ;
crate ::db ::log_event (
" buff " ,
" warn " ,
& format! ( " 祖先判定失败,本批跳过任务卡推断: {detail} " ) ,
Some ( & root . to_string_lossy ( ) ) ,
) ;
false
}
}
}
/// 本批提交对 .blueprint/modules 的 patch 文本; git 失败返回 None。
fn get_blueprint_patch ( root : & Path , last_commit : Option < & str > , head : & str ) -> Option < String > {
let mut cmd = crate ::silent_cmd ( " git " ) ;
match last_commit {
Some ( prev ) = > cmd . args ( [ " diff " , " --no-color " , " --no-ext-diff " , prev , head ] ) ,
None = > cmd . args ( [
" diff-tree " ,
" -p " ,
" --no-color " ,
" --no-commit-id " ,
" --root " ,
head ,
] ) ,
} ;
let out = cmd
. args ( [ " -- " , " .blueprint/modules " ] )
. current_dir ( root )
. output ( )
. ok ( ) ? ;
out . status
. success ( )
. then ( | | String ::from_utf8_lossy ( & out . stdout ) . into_owned ( ) )
}
// ── 辅助:新增 commit 解析(飞轮 L0) ─────────────────────────────────────────
/// 首次接入时只解析最近这么多条 commit, 避免一次性解析整个历史。
@ -310,6 +364,53 @@ fn get_new_commits(
// ── 辅助:任务状态更新 ────────────────────────────────────────────────────────
/// files 重叠推断只作兜底: head 由 last_commit 前进而来、且本批提交没有显式推进任何任务卡时,
/// 才把 files 命中的 📋 卡标 🔵。返回被标记的卡数。
///
/// 为什么是兜底: agent 按两阶段锁开工前已把在做的卡标 🔵,提交落地时仍是 📋 的卡多半没在做,
/// files 重叠( 尤其共享热点文件) 命中的恰是它们——2026-10-02 回放 master 历史 50 次命中仅 3 次明确正确。
/// 本批已有卡被推进说明归属已显式, 不再猜; 整批无推进才视为「agent 忘了加锁」。
fn infer_task_marks ( root : & Path , last_commit : Option < & str > , head : & str ) -> Result < usize , String > {
if let Some ( prev ) = last_commit {
if ! is_ancestor ( root , prev , head ) {
return Ok ( 0 ) ;
}
}
let changed = get_changed_files ( root , last_commit , head ) ? ;
if changed . is_empty ( ) {
return Ok ( 0 ) ;
}
match get_blueprint_patch ( root , last_commit , head ) {
Some ( patch ) if ! has_explicit_advance ( & patch ) = > update_task_statuses ( root , & changed ) ,
_ = > Ok ( 0 ) ,
}
}
/// 蓝图 patch 中是否有任务卡被显式推进:新增了 `### 🔵` / `### ✅` / `### 🔴` 标题行,
/// 且不是同一行的原样搬移(删除侧有完全相同的行视为重排,不算推进)。
/// 🔴 也算: agent 把卡标为受阻,说明它正在做那张卡,归属同样已显式。
fn has_explicit_advance ( patch : & str ) -> bool {
let is_advanced = | l : & str | {
[ " ### 🔵 " , " ### ✅ " , " ### 🔴 " ]
. iter ( )
. any ( | p | l . starts_with ( p ) )
} ;
let mut added = Vec ::new ( ) ;
let mut removed = std ::collections ::HashSet ::new ( ) ;
for line in patch . lines ( ) . map ( | l | l . trim_end_matches ( '\r' ) ) {
if let Some ( rest ) = line . strip_prefix ( '+' ) {
if is_advanced ( rest ) {
added . push ( rest ) ;
}
} else if let Some ( rest ) = line . strip_prefix ( '-' ) {
if is_advanced ( rest ) {
removed . insert ( rest ) ;
}
}
}
added . iter ( ) . any ( | l | ! removed . contains ( l ) )
}
fn update_task_statuses ( root : & Path , changed_files : & [ String ] ) -> Result < usize , String > {
let modules_dir = root . join ( " .blueprint " ) . join ( " modules " ) ;
if ! modules_dir . exists ( ) {
@ -367,13 +468,11 @@ fn mark_tasks_in_progress(content: &str, changed_files: &[String]) -> (String, u
}
if let Some ( fv ) = files_value {
let task_files : Vec < String > = fv . split ( ',' ) . map ( | s | normalize_path ( s . trim ( ) ) ) . collect ( ) ;
let task_files = parse_task_files ( & fv ) ;
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 ( ) )
} )
task_files . iter ( ) . any ( | tf | file_matches ( tf , & ncf ) )
} ) ;
if hit {
@ -397,6 +496,24 @@ fn mark_tasks_in_progress(content: &str, changed_files: &[String]) -> (String, u
( result , updated )
}
/// 解析任务卡 files 字段:逗号、顿号、全角逗号均作分隔;空项丢弃(尾逗号产生的空串会命中一切改动)。
fn parse_task_files ( value : & str ) -> Vec < String > {
value
. split ( [ ',' , '、' , ', ' ] )
. map ( | s | normalize_path ( s . trim ( ) ) )
. filter ( | s | ! s . is_empty ( ) )
. collect ( )
}
/// 任务卡路径与改动路径是否匹配:完全相同,或任务卡写的是路径后缀(须落在 `/` 边界)。
/// 不做反向后缀( 任务卡路径更长) ——它曾让未拆开的「a.rs、b.rs」整串命中 b.rs。
fn file_matches ( task_file : & str , changed : & str ) -> bool {
changed = = task_file
| | changed
. strip_suffix ( task_file )
. is_some_and ( | prefix | prefix . ends_with ( '/' ) )
}
fn normalize_path ( p : & str ) -> String {
p . trim_start_matches ( " ./ " ) . replace ( '\\' , " / " ) . to_string ( )
}
@ -435,3 +552,185 @@ fn query_buff(conn: &rusqlite::Connection, path: &str) -> Result<BuffStatus, Str
)
. map_err ( | e | e . to_string ( ) )
}
#[ cfg(test) ]
mod tests {
use super ::* ;
fn card ( emoji : & str , title : & str , files : & str ) -> String {
let status = if emoji = = " 📋 " {
" todo "
} else {
" in_progress "
} ;
format! ( " ### {emoji} {title} \n - status: {status} \n - files: {files} \n - acceptance: x \n \n " )
}
fn changed ( files : & [ & str ] ) -> Vec < String > {
files . iter ( ) . map ( | s | s . to_string ( ) ) . collect ( )
}
// ── 匹配规则 ──
#[ test ]
fn exact_match_marks_todo_card ( ) {
let md = format! (
" ## 任务卡 \n \n {} " ,
card ( " 📋 " , " P8 — x " , " src/a.rs, src/b.rs " )
) ;
let ( out , n ) = mark_tasks_in_progress ( & md , & changed ( & [ " src/b.rs " ] ) ) ;
assert_eq! ( n , 1 ) ;
assert! ( out . contains ( " ### 🔵 P8 — x " ) ) ;
assert! ( out . contains ( " - status: in_progress " ) ) ;
assert! ( out . ends_with ( '\n' ) , " 保留末尾换行 " ) ;
}
#[ test ]
fn non_todo_cards_untouched ( ) {
let md = card ( " ✅ " , " P1 " , " src/a.rs " ) + & card ( " 🔵 " , " P2 " , " src/a.rs " ) ;
let ( out , n ) = mark_tasks_in_progress ( & md , & changed ( & [ " src/a.rs " ] ) ) ;
assert_eq! ( ( n , out . as_str ( ) ) , ( 0 , md . as_str ( ) ) ) ;
}
#[ test ]
fn longer_task_path_does_not_match_shorter_change ( ) {
// 反向后缀已移除:任务卡路径更长(如别的仓库的绝对路径)不命中
let md = card ( " 📋 " , " P3-E " , " E:/other-repo/deploy/README.md " ) ;
let ( _ , n ) = mark_tasks_in_progress ( & md , & changed ( & [ " deploy/README.md " ] ) ) ;
assert_eq! ( n , 0 ) ;
}
#[ test ]
fn suffix_match_requires_path_boundary ( ) {
assert! ( file_matches ( " lib.rs " , " src/lib.rs " ) ) ;
assert! ( file_matches (
" commands/buff.rs " ,
" src-tauri/src/commands/buff.rs "
) ) ;
assert! ( ! file_matches ( " lib.rs " , " src/mylib.rs " ) ) ;
assert! ( ! file_matches ( " src/a.rs " , " src/a.rs.bak " ) ) ;
}
#[ test ]
fn trailing_comma_empty_entry_matches_nothing ( ) {
let md = card ( " 📋 " , " P5 " , " src/a.rs, " ) ;
let ( _ , n ) = mark_tasks_in_progress ( & md , & changed ( & [ " unrelated.txt " ] ) ) ;
assert_eq! ( n , 0 ) ;
}
#[ test ]
fn chinese_separators_split_entries ( ) {
assert_eq! (
parse_task_files ( " a.rs、 b.rs, c.rs, ./d.rs " ) ,
vec! [ " a.rs " , " b.rs " , " c.rs " , " d.rs " ]
) ;
// 2026-10-02 实例: A4 的 files 用「、」分隔,旧逻辑整串靠反向后缀误命中
let md = card (
" 📋 " ,
" A4 " ,
" src-tauri/src/mcp_server.rs、src-tauri/src/mcp_tools.rs " ,
) ;
let ( _ , n ) = mark_tasks_in_progress ( & md , & changed ( & [ " src-tauri/src/mcp_server.rs " ] ) ) ;
assert_eq! ( n , 1 ) ;
}
// ── 显式推进判定 ──
#[ test ]
fn added_in_progress_or_done_heading_is_explicit_advance ( ) {
let patch = " --- a/.blueprint/modules/m.md \n +++ b/.blueprint/modules/m.md \n \
@ @ - 1 , 2 + 1 , 2 @ @ \ n - ### 📋 P1 — x \ n + ### 🔵 P1 — x \ n " ;
assert! ( has_explicit_advance ( patch ) ) ;
assert! ( has_explicit_advance ( " +### ✅ P9 — y \r \n " ) ) ;
assert! (
has_explicit_advance ( " -### 🔵 P2 — z \n +### 🔴 P2 — z \n " ) ,
" 标为受阻同样是显式归因 "
) ;
}
#[ test ]
fn todo_edits_and_moved_headings_are_not_advance ( ) {
assert! ( ! has_explicit_advance (
" -### 📋 P8 — x \n +### 📋 P8 — x( 改标题) \n "
) ) ;
// 卡片整段搬移:同一行一删一增
assert! ( ! has_explicit_advance ( " -### ✅ P1 — x \n +### ✅ P1 — x \n " ) ) ;
assert! ( ! has_explicit_advance ( " " ) ) ;
}
// ── 端到端:临时 git 仓库 ──
fn git ( dir : & Path , args : & [ & str ] ) -> String {
let out = std ::process ::Command ::new ( " git " )
. args ( [
" -c " ,
" user.name=t " ,
" -c " ,
" user.email=t@t " ,
" -c " ,
" commit.gpgsign=false " ,
" -c " ,
" core.autocrlf=false " ,
] )
. args ( args )
. current_dir ( dir )
. output ( )
. expect ( " git 可执行 " ) ;
assert! (
out . status . success ( ) ,
" git {args:?}: {} " ,
String ::from_utf8_lossy ( & out . stderr )
) ;
String ::from_utf8_lossy ( & out . stdout ) . trim ( ) . to_string ( )
}
fn commit_all ( dir : & Path , msg : & str ) -> String {
git ( dir , & [ " add " , " -A " ] ) ;
git ( dir , & [ " commit " , " -q " , " --no-verify " , " -m " , msg ] ) ;
git ( dir , & [ " rev-parse " , " HEAD " ] )
}
#[ test ]
fn infer_only_as_fallback_and_only_when_head_advances ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " buff_infer_test_ {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
let modules = dir . join ( " .blueprint " ) . join ( " modules " ) ;
std ::fs ::create_dir_all ( & modules ) . unwrap ( ) ;
let md_path = modules . join ( " m.md " ) ;
let md =
| p1 : & str | card ( p1 , " P1 — 在做 " , " src/a.rs " ) + & card ( " 📋 " , " P8 — 未开工 " , " src/a.rs " ) ;
let read_md = | | std ::fs ::read_to_string ( & md_path ) . unwrap ( ) ;
git ( & dir , & [ " init " , " -q " ] ) ;
std ::fs ::create_dir_all ( dir . join ( " src " ) ) . unwrap ( ) ;
std ::fs ::write ( dir . join ( " src/a.rs " ) , " 1 " ) . unwrap ( ) ;
std ::fs ::write ( & md_path , md ( " 📋 " ) ) . unwrap ( ) ;
let c1 = commit_all ( & dir , " init " ) ;
// ① 本批 agent 已显式把 P1 标 🔵 → 共用文件的 P8 不被推断
std ::fs ::write ( dir . join ( " src/a.rs " ) , " 2 " ) . unwrap ( ) ;
std ::fs ::write ( & md_path , md ( " 🔵 " ) ) . unwrap ( ) ;
let c2 = commit_all ( & dir , " feat: P1 " ) ;
assert_eq! ( infer_task_marks ( & dir , Some ( & c1 ) , & c2 ) . unwrap ( ) , 0 ) ;
assert! ( read_md ( ) . contains ( " ### 📋 P8 " ) ) ;
// ② 本批无任何显式推进( agent 忘了加锁)→ files 重叠兜底标 🔵
std ::fs ::write ( dir . join ( " src/a.rs " ) , " 3 " ) . unwrap ( ) ;
let c3 = commit_all ( & dir , " fix: a " ) ;
assert_eq! ( infer_task_marks ( & dir , Some ( & c2 ) , & c3 ) . unwrap ( ) , 1 ) ;
assert! ( read_md ( ) . contains ( " ### 🔵 P8 " ) ) ;
git ( & dir , & [ " checkout " , " -- " , " . " ] ) ;
// ⑦ checkout 到不含 last_commit 的分支: HEAD 非前进,不推断
git ( & dir , & [ " checkout " , " -q " , " -b " , " side " , & c1 ] ) ;
std ::fs ::write ( dir . join ( " src/a.rs " ) , " side " ) . unwrap ( ) ;
let c4 = commit_all ( & dir , " side " ) ;
assert_eq! ( infer_task_marks ( & dir , Some ( & c3 ) , & c4 ) . unwrap ( ) , 0 ) ;
assert! ( read_md ( ) . contains ( " ### 📋 P8 " ) ) ;
// 首次接入(无 last_commit) : 只看 HEAD 单个提交,无显式推进 → 兜底生效
assert_eq! ( infer_task_marks ( & dir , None , & c4 ) . unwrap ( ) , 2 ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
}