Skip to main content

code_repo_wiki/incremental/
mod.rs

1pub mod change;
2pub mod diff;
3pub mod impact;
4pub mod state;
5pub mod watch;
6
7use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10
11use crate::config::schema::WikiConfig;
12use crate::ingest::parser::FileInsight;
13use crate::model::KnowledgeGraph;
14use crate::project::ProjectRoot;
15
16use self::change::{classify_entity_changes_at, no_entity_change_files, EntityChangeSet};
17use self::impact::{propagate_impact, propagate_impact_semantic};
18use self::state::GenerationState;
19
20/// no-op 快速跳过判定(v19 t06,OpenWiki git-head 模式)
21///
22/// 定时 CI / watch 常驻场景下,无变更时 update 仍会全量扫描 + 分析
23/// (状态推进前每次跑全量),成本可观。本判定在扫描之前完成,全部
24/// 满足才跳过:
25/// 1. 状态文件可读且 last_commit_hash 存在(上次成功生成过)
26/// 2. 当前 git HEAD == last_commit_hash(无新提交)
27/// 3. 源码范围(全仓库)内工作树无未提交变更(git status 过滤——
28///    产物目录的改动不算,否则本仓库自身产物会恒阻断跳过)
29/// 4. 产物信号存在(wiki 目录)——产物被删时不得跳过(no-op 固有盲区)
30///
31/// 为什么不需要 interrupted 标记(与 OpenWiki 方案的差异,G3 批判审查):
32/// 中途失败(Ctrl-C/panic/LLM 故障)时生成状态不推进 last_commit_hash——
33/// 失败前有新提交则条件 2 不满足;失败前只有未提交变更则条件 3 不满足;
34/// 失败且无任何变更(纯外部故障)则产物与代码均未变化,跳过无损失。
35/// head + statuses 双判据已完备覆盖"防半程状态被误判",interrupted 冗余。
36///
37/// 保守边界:状态损坏/非 git 仓库/无 HEAD/status 读取失败一律返回 false
38/// (不跳过,走正常路径——正常路径对同样情况有各自的回退语义)。
39pub fn should_skip_noop(root: &ProjectRoot, config: &WikiConfig) -> anyhow::Result<bool> {
40    // 1. 状态可读 + 有基线(生成完成的最后 commit)
41    let state_dir = config.output_dir().join(".state");
42    let state = match GenerationState::load(&state_dir) {
43        Ok(s) => s,
44        Err(_) => return Ok(false),
45    };
46    let Some(last_hash) = state.last_commit_hash else {
47        return Ok(false);
48    };
49    // 5. 失败补偿信号(v22 修复):上次生成有模块失败待重试时不可跳过——
50    // 无变更时的快速判定会把失败模块的补生成一并跳过,使失败永久残留
51    if !state.failed_modules.is_empty() {
52        return Ok(false);
53    }
54    // 4. 产物信号:wiki 目录存在(被删时跳过会让缺失产物保持缺失)
55    if !config.output_dir().join("wiki").exists() {
56        return Ok(false);
57    }
58    // 2. 当前 HEAD 与基线一致(git2 读失败/无 HEAD = 保守不跳过)
59    let repo = match git2::Repository::open(root.path()) {
60        Ok(r) => r,
61        Err(_) => return Ok(false),
62    };
63    let head_hash = match repo.head() {
64        Ok(head) => head.peel_to_commit().ok().map(|c| c.id().to_string()),
65        Err(_) => return Ok(false),
66    };
67    if head_hash.as_deref() != Some(last_hash.as_str()) {
68        return Ok(false);
69    }
70    // 3. 源码范围(全仓库)内工作树无未提交变更
71    if status_has_source_changes(&repo, config, root) {
72        return Ok(false);
73    }
74    Ok(true)
75}
76
77/// 工作树是否有源码未提交变更(相对仓库根判断)
78///
79/// v30+:扫描范围已硬编码为全量遍历,任何路径都算源码变更——
80/// 例外只有工具自身产物:产物目录(output_dir,含 .code-repo-wiki/.state/
81/// .search)与仓库根 AGENTS.md(generate 自动写入的代理导航模板,
82/// 非源码、不参与生成)。产物若算作变更则每次生成后 no-op 判定恒为
83/// false(快速跳过永久失效)。被忽略目录(.gitignore 已忽略产物)
84/// 不受影响,此处显式排除是为了未忽略产物的仓库也不退化。
85///
86/// 路径比较统一走 norm_sep(正斜杠):git2 status 路径恒用正斜杠,
87/// Windows 上 Path::starts_with 的反斜杠比较会失配。
88fn status_has_source_changes(
89    repo: &git2::Repository,
90    config: &WikiConfig,
91    root: &ProjectRoot,
92) -> bool {
93    // 产物目录相对仓库根归一化(output_dir 可能是 root 化注入的绝对路径)
94    let out_abs = config.output_dir().to_string_lossy().replace('\\', "/");
95    let root_abs = root.path().to_string_lossy().replace('\\', "/");
96    let out_norm = out_abs.strip_prefix(&root_abs).map(|s| s.trim_start_matches('/')).unwrap_or(&out_abs);
97    match repo.statuses(None) {
98        Ok(statuses) => statuses.iter().any(|s| {
99            let Some(path) = s.path() else { return false };
100            let norm = norm_sep(path);
101            let in_output = norm == out_norm || norm.starts_with(&format!("{out_norm}/"));
102            let is_agents_md = norm == "AGENTS.md";
103            !(in_output || is_agents_md)
104        }),
105        // status 读取失败:保守视为有变更(不跳过)——no-op 跳过只允许
106        // 在证据齐全时发生,证据缺失时必须走正常路径
107        Err(_) => true,
108    }
109}
110
111/// 路径分隔符归一化(Windows 兼容)
112///
113/// git2 的 delta 路径恒用正斜杠("src/net/tcp.rs"),而 scanner/insight
114/// 的路径在 Windows 上是反斜杠("src\net\tcp.rs")——所有跨来源的
115/// 路径比较(影响传播起点匹配、实体变化分类、增量生成过滤)必须先
116/// 归一化,否则 Windows 上传播/分类/过滤全部失效(子串 contains 与
117/// HashSet 精确匹配都按字节比较)。
118pub(crate) fn norm_sep(p: &str) -> String {
119    p.replace('\\', "/")
120}
121
122/// 增量更新结果
123pub struct IncrementalResult {
124    /// 实际发生变更的文件路径(用于 LLM 生成过滤)
125    pub changed_files: Vec<std::path::PathBuf>,
126    /// 受影响的模块名称列表(用于日志和下游分析)
127    pub affected_modules: Vec<String>,
128    /// 实体级变化分类(GitDiff 策略产出;FileWatch 策略为空——
129    /// 下游据此跳过实体级摘要过滤,见 generate::run_generation_filtered)
130    pub entity_changes: EntityChangeSet,
131    /// 本次变更是否含已删除文件(GitDiff 策略 = diff 删除集非空;
132    /// FileWatch 策略 = 变更集中存在磁盘上已不存在的路径)。
133    ///
134    /// 纯删除时被删文件无 Insight、传播起点为空,changed_insights 为空
135    /// 会走快照回填分支——该分支只按"整模块文件全删"过滤,多文件模块
136    /// 删一文件时页面回填旧内容残留被删实体;同时全局文档(架构/概览/
137    /// index)回填旧版继续列出已删模块。此信号让下游(generate 回填分支
138    /// 与 lib.rs 的 index 门控)识别删除场景并走重生成路径(v21 验证轮修复)。
139    pub has_deleted_files: bool,
140}
141
142/// 在指定项目根下运行增量更新分析
143///
144/// root 注入链路:git 仓库定位与实体变化分类的仓库根由 root 显式给出
145/// 全部以 root 为基准,不再依赖进程 cwd——测试可在临时目录构造
146/// ProjectRoot 验证增量逻辑,watch 常驻进程的 cwd 漂移不再改变
147/// git 仓库解析目标。
148pub fn run_incremental_update_at(
149    root: &ProjectRoot,
150    insights: &[FileInsight],
151    graph: &KnowledgeGraph,
152    config: &WikiConfig,
153    watch_paths: &[PathBuf],
154) -> Result<IncrementalResult> {
155    // v30:增量策略硬编码为 FileWatch(文件内容指纹比对,不依赖 git 提交)——
156    // 外部 Agent 保存文件后直接 update 即可生效;指纹=内容 SHA256,git 回滚
157    // 也能检出。增量开关同样硬编码恒启用,force 参数仍可强制全量。
158    let state_dir = config.output_dir().join(".state");
159
160    // v30:恒走 FileWatch 指纹路径(GitDiff 策略已随配置字段一并删除)
161    let (changed_files, affected_modules, entity_changes, has_deleted_files) =
162        run_file_watch_incremental(root, insights, graph, &state_dir, watch_paths)?;
163
164    // v22 失败补偿重试:上次生成失败的模块并入本次变更集(存活文件)。
165    // 失败隔离(record_failure)只跳过失败模块不中断整体,但失败模块若
166    // 源码不再变更将永远无法补生成(增量以 git diff 触发,失败模块不在
167    // diff 中)。重试语义:模块的存活文件视同本次变更,走正常生成路径;
168    // 依赖方由传播机制自然覆盖。清空时机:重试成功或全量生成。
169    //
170    // 模块→文件映射从导出快照取(cards.related_files):failed_modules
171    // 记录的是 chunk 模块名(社区名,与卡片 module_name 同体系),而
172    // graph 节点的 module_path 是文件路径体系——用 module_files 匹配
173    // 社区名会永远落空(Unity 实测补偿未触发)。快照是唯一同时携带
174    // 两套信息的持久化载体。
175    let mut changed_files = changed_files;
176    if let Ok(state) = GenerationState::load(&state_dir)
177        && !state.failed_modules.is_empty()
178    {
179        let snapshot_path =
180            crate::output::export_snapshot_path(config.output_dir());
181        let mut failed_files: Vec<std::path::PathBuf> = Vec::new();
182        if let Ok(content) = std::fs::read_to_string(&snapshot_path)
183            && let Ok(snapshot) =
184                serde_json::from_str::<crate::output::ExportSnapshot>(&content)
185        {
186            for card in &snapshot.cards {
187                if state.failed_modules.contains(&card.module_name) {
188                    for rf in &card.related_files {
189                        let p = std::path::PathBuf::from(rf);
190                        if !failed_files.contains(&p) {
191                            failed_files.push(p);
192                        }
193                    }
194                }
195            }
196        } else {
197            // 快照缺失/损坏:补偿无法定位模块文件,保守不并入(本次
198            // update 走正常路径;失败模块等下次生成自然覆盖)
199            tracing::warn!(
200                "失败补偿重试:导出快照不可读({}),跳过补偿并入",
201                snapshot_path.display()
202            );
203        }
204        let mut merged = changed_files.clone();
205        // 存活判定相对仓库根(快照 related_files 是相对项目根的路径,
206        // 直接 exists() 会相对进程 cwd 误判)
207        for f in failed_files {
208            if root.path().join(&f).exists() && !merged.contains(&f) {
209                merged.push(f);
210            }
211        }
212        if merged.len() > changed_files.len() {
213            tracing::warn!(
214                "检测到上次生成失败模块({} 个),并入本次变更集补偿重试",
215                state.failed_modules.len()
216            );
217            changed_files = merged;
218        }
219    }
220
221    Ok(IncrementalResult { changed_files, affected_modules, entity_changes, has_deleted_files })
222}
223
224/// FileWatch 策略的增量更新
225///
226/// root 注入:git 仓库定位与实体变化分类的仓库根都由 root 显式给出
227/// (私有函数,签名由公开入口 run_incremental_update_at 统一约束)。
228fn run_file_watch_incremental(
229    root: &ProjectRoot,
230    insights: &[FileInsight],
231    graph: &KnowledgeGraph,
232    state_dir: &Path,
233    watch_paths: &[PathBuf],
234) -> Result<(Vec<PathBuf>, Vec<String>, EntityChangeSet, bool)> {
235    // 重新加载状态,比较文件指纹;状态损坏/缺失时全部文件视为变更
236    // (回退全量),不能静默吞错——与 GitDiff 路径的 warn 处理一致
237    let state = match GenerationState::load(state_dir) {
238        Ok(s) => Some(s),
239        Err(e) => {
240            tracing::warn!("状态文件读取失败,按无基线处理(本次全量变更): {}", e);
241            None
242        }
243    };
244    let mut changed_files: Vec<PathBuf> = Vec::new();
245
246    for insight in insights {
247        if let Ok(true) = state.as_ref().map(|s| s.is_file_changed(root, &insight.path)).unwrap_or(Ok(true)) {
248            changed_files.push(insight.path.clone());
249        }
250    }
251
252    // 并入外部 watch 事件路径(去重):事件路径是变更的直接证据,不再只依赖指纹比对。
253    // 指纹比对保留,用于兜底 watch 事件丢失的变更(防抖窗口冲突、事件丢失等),两者取并集。
254    // 不存在的路径(删除事件)原样进入 changed_files,供下游 cleanup_deleted_outputs
255    // 以 exists() 判断并清理旧输出——删除文件不在 insights 里,指纹比对永远捕获不到。
256    for p in watch_paths {
257        if !changed_files.contains(p) {
258            changed_files.push(p.clone());
259        }
260    }
261
262    // v30 补强:纯 update(无 watch 事件)也能检出文件删除——旧指纹表中
263    // 本次 insights 已不存在的路径即删除事件(磁盘已删,下游按 exists()
264    // 判断清理旧输出;与 watch 删除事件同一处理路径)。
265    if let Some(state) = &state {
266        for path_str in state.file_fingerprints.keys() {
267            let p = PathBuf::from(path_str);
268            if !insights.iter().any(|i| i.path == p) && !changed_files.contains(&p) {
269                changed_files.push(p);
270            }
271        }
272    }
273
274    if changed_files.is_empty() {
275        tracing::info!("无文件变更");
276        return Ok((Vec::new(), Vec::new(), EntityChangeSet::default(), false));
277    }
278
279    // 实体级变化分类(修复:FileWatch 路径原为空集,接口级变化无法驱动
280    // 语义传播与实体级摘要过滤,README 声称与实现差距)。FileWatch 无
281    // Git diff,但 classify_entity_changes_at 需要旧内容做对比——用状态里
282    // 记录的 last_commit_hash 作基准从 Git 树读旧内容;变更集中磁盘上
283    // 仍存在的文件视为 modified(新增文件对比旧树为空 → 全部 Added),
284    // 磁盘上已不存在的视为 deleted。非 Git 仓库或无上次 commit 时分类
285    // 内部返回空集,回退保守的双向传播(与 GitDiff 路径失败回退一致)。
286    let (entity_changes, classification_failed) = if let Some(state) = &state {
287        // git tree 查询需要相对仓库根的路径(insight.path 可能是绝对路径,
288        // 直接传入会让旧实体读取 miss 而误判为 Added→接口级双向传播误伤
289        // 依赖方,见 test_incremental_git_e2e 场景 A 回归)
290        let rel = |p: &PathBuf| p.strip_prefix(root.path()).unwrap_or(p).to_path_buf();
291        // exists() 必须以 root 为基准(changed_files 是相对路径,裸判
292        // exists 落在进程 cwd 上——测试/守护进程 cwd 是仓库根时,相对
293        // 路径全部误判为"已删除"→实体被误标 Removed→接口级双向传播
294        // 误伤依赖方,见 test_incremental_git_e2e 场景 A 回归)
295        let exists_at_root = |p: &PathBuf| root.path().join(p).exists();
296        let diff = crate::incremental::diff::GitDiffResult {
297            modified: changed_files
298                .iter()
299                .filter(|p| exists_at_root(p))
300                .map(rel)
301                .collect(),
302            deleted: changed_files
303                .iter()
304                .filter(|p| !exists_at_root(p))
305                .map(rel)
306                .collect(),
307            from_commit: state.last_commit_hash.clone().unwrap_or_default(),
308            ..Default::default()
309        };
310        match classify_entity_changes_at(root, &diff, insights) {
311            Ok(set) => (set, false),
312            Err(e) => {
313                tracing::warn!("FileWatch 实体级变化分类失败,回退双向传播: {}", e);
314                (EntityChangeSet::default(), true)
315            }
316        }
317    } else {
318        (EntityChangeSet::default(), true)
319    };
320    // v23 A1:与 GitDiff 路径同口径——分类成功时才剔除无实体变更文件
321    // (纯空白/注释变化),分类失败/无状态(保守回退)保留全部起点。
322    let changed_for_impact = if classification_failed {
323        changed_files.clone()
324    } else {
325        let no_entity_change = no_entity_change_files(&changed_files, &entity_changes, root);
326        changed_files
327            .iter()
328            .filter(|f| !no_entity_change.contains(*f))
329            .cloned()
330            .collect()
331    };
332    let affected_modules = if entity_changes.changes.is_empty() {
333        propagate_impact(&changed_for_impact, graph, crate::config::schema::IMPACT_MAX_DEPTH)
334    } else {
335        propagate_impact_semantic(&changed_for_impact, &entity_changes, graph, crate::config::schema::IMPACT_MAX_DEPTH)
336    };
337
338    // 保存新状态
339    // 同上(票 03):FileWatch 中途存盘同样合并旧状态保护字段;
340    // 复用前面已加载的 state(Option),不存在二次 load 静默失败路径
341    if let Ok(mut new_state) = GenerationState::from_insights(root, insights, "file-watch") {
342        if let Some(old_state) = &state {
343            new_state.preserve_protection(old_state);
344        }
345        if let Err(e) = new_state.save(state_dir) {
346            tracing::warn!("保存生成状态失败: {}", e);
347        }
348    }
349
350    tracing::info!("FileWatch 增量分析完成: {} 个模块受影响", affected_modules.len());
351    // FileWatch 的删除判定 = 变更集中存在磁盘上已不存在的路径(删除事件)
352    let has_deleted = changed_files.iter().any(|p| !p.exists());
353    Ok((changed_files, affected_modules, entity_changes, has_deleted))
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::project::ProjectRoot;
360
361    fn make_insight(path: &str) -> FileInsight {
362        FileInsight {
363            path: std::path::PathBuf::from(path),
364            language: "rust".into(),
365            entities: Vec::new(),
366            imports: Vec::new(),
367            doc_comments: Vec::new(),
368            source: String::new(),
369        }
370    }
371
372    fn make_config() -> WikiConfig {
373        WikiConfig {
374            ..Default::default()
375        }
376    }
377
378    /// 非 Git 目录:GitDiff 增量回退全量(changed_files = 所有 insights 路径)
379    /// 行数超限:回退全量
380    /// A1:删除文件后增量更新,被删路径必须进入 changed_files,
381    /// 下游删除清理(cleanup_deleted_outputs)才能命中并清除旧输出
382    #[test]
383    fn test_deleted_files_in_changed_set() {
384        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_deleted_changed_{}", std::process::id()));
385        let _ = std::fs::remove_dir_all(&dir);
386        let repo = git2::Repository::init(&dir).unwrap();
387        let mut cfg = repo.config().unwrap();
388        cfg.set_str("user.name", "test").unwrap();
389        cfg.set_str("user.email", "test@test.com").unwrap();
390
391        // 第一次提交:src/foo.rs 存在
392        let src = dir.join("src");
393        std::fs::create_dir_all(&src).unwrap();
394        std::fs::write(src.join("foo.rs"), "fn foo() {}\n").unwrap();
395        let mut index = repo.index().unwrap();
396        index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
397        index.write().unwrap();
398        let tree_id = index.write_tree().unwrap();
399        let tree = repo.find_tree(tree_id).unwrap();
400        let sig = git2::Signature::now("test", "test@test.com").unwrap();
401        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
402        // 基线 = 第一 commit:diff 才能覆盖第二次提交的删除变更
403        let first_hash = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
404
405        // 基线 = 第一 commit 时含 foo.rs 的状态(文件仍存在,指纹可计算;
406        // v30 FileWatch 删除检测=旧指纹∖本次 insights,指纹表必须有 foo.rs)
407        let state_dir = dir.join(".state");
408        let baseline_insights = vec![make_insight("src/foo.rs")];
409        let baseline = GenerationState::from_insights(
410            &crate::project::ProjectRoot::new(dir.clone()),
411            &baseline_insights,
412            &first_hash,
413        )
414        .unwrap();
415        baseline.save(&state_dir).unwrap();
416
417        // 第二次提交:删除 src/foo.rs(index.remove_path 后再提交;
418        // 必须放在建状态之后——指纹计算需要文件仍在磁盘上)
419        std::fs::remove_file(src.join("foo.rs")).unwrap();
420        let mut index = repo.index().unwrap();
421        index.remove_path(Path::new("src/foo.rs")).unwrap();
422        index.write().unwrap();
423        let tree_id = index.write_tree().unwrap();
424        let tree = repo.find_tree(tree_id).unwrap();
425        repo.commit(Some("HEAD"), &sig, &sig, "delete", &tree, &[&repo.head().unwrap().peel_to_commit().unwrap()]).unwrap();
426
427        // insights 只含现存文件(被删文件不在其中)
428        let insights: Vec<FileInsight> = Vec::new();
429        let graph = KnowledgeGraph::default();
430        let state_dir = dir.join(".state");
431
432        let (changed, _affected, _entity_changes, _has_deleted) =
433            run_file_watch_incremental(&ProjectRoot::new(dir.clone()), &insights, &graph, &state_dir, &[]).unwrap();
434        assert!(
435            changed.iter().any(|p| p == Path::new("src/foo.rs")),
436            "被删文件路径应计入 changed_files(否则删除清理与索引清理永不触发): {:?}",
437            changed
438        );
439
440        let _ = std::fs::remove_dir_all(&dir);
441    }
442
443    /// FileWatch 核心修复:watch 事件传入已删除路径(磁盘不存在)时,
444    /// 删除路径必须进入 changed_files,下游 cleanup_deleted_outputs 才能
445    /// 清理旧输出——删除文件不在 insights 中,指纹比对永远捕获不到
446    #[test]
447    fn test_file_watch_deleted_path_in_changed_files() {
448        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_watch_deleted_{}", std::process::id()));
449        let _ = std::fs::remove_dir_all(&dir);
450        let src = dir.join("src");
451        std::fs::create_dir_all(&src).unwrap();
452        std::fs::write(src.join("a.rs"), "v1").unwrap();
453
454        // 先保存状态:a.rs 内容未变,指纹比对不命中
455        let insight = make_insight(src.join("a.rs").to_string_lossy().as_ref());
456        let state_dir = dir.join(".state");
457        let root = crate::project::ProjectRoot::new(dir.clone());
458        let state = GenerationState::from_insights(&root, std::slice::from_ref(&insight), "test").unwrap();
459        state.save(&state_dir).unwrap();
460
461        // watch 事件传入已删除路径(磁盘上不存在)
462        let deleted = src.join("b.rs");
463        let graph = KnowledgeGraph::default();
464
465        let (changed, _affected, _entity_changes, _has_deleted) =
466            run_file_watch_incremental(&root, std::slice::from_ref(&insight), &graph, &state_dir, std::slice::from_ref(&deleted)).unwrap();
467        assert_eq!(
468            changed,
469            vec![deleted],
470            "删除路径必须进入 changed_files(否则删除清理永不触发)"
471        );
472
473        let _ = std::fs::remove_dir_all(&dir);
474    }
475
476    /// FileWatch 并集语义:指纹命中与外部 watch 路径取并集
477    /// (指纹覆盖 watch 事件丢失的变更,watch 覆盖指纹捕获不到的删除)
478    #[test]
479    fn test_file_watch_union_fingerprint_and_watch_paths() {
480        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_watch_union_{}", std::process::id()));
481        let _ = std::fs::remove_dir_all(&dir);
482        let src = dir.join("src");
483        std::fs::create_dir_all(&src).unwrap();
484        std::fs::write(src.join("a.rs"), "v1").unwrap();
485
486        // 保存状态(a.rs 指纹 v1),随后修改内容 → 指纹比对命中
487        let insight = make_insight(src.join("a.rs").to_string_lossy().as_ref());
488        let state_dir = dir.join(".state");
489        let root = crate::project::ProjectRoot::new(dir.clone());
490        let state = GenerationState::from_insights(&root, std::slice::from_ref(&insight), "test").unwrap();
491        state.save(&state_dir).unwrap();
492        std::fs::write(src.join("a.rs"), "v2").unwrap();
493
494        // watch 事件传入另一路径(磁盘上不存在,模拟删除)
495        let deleted = src.join("b.rs");
496        let graph = KnowledgeGraph::default();
497
498        let (changed, _affected, _entity_changes, _has_deleted) =
499            run_file_watch_incremental(&root, std::slice::from_ref(&insight), &graph, &state_dir, std::slice::from_ref(&deleted)).unwrap();
500        assert!(
501            changed.contains(&insight.path),
502            "指纹命中的文件应计入: {:?}",
503            changed
504        );
505        assert!(
506            changed.contains(&deleted),
507            "watch 路径应并入(与指纹命中取并集): {:?}",
508            changed
509        );
510        assert_eq!(changed.len(), 2, "指纹与 watch 路径取并集,不应重复");
511
512        let _ = std::fs::remove_dir_all(&dir);
513    }
514
515    /// 票 03:中途存盘必须保留旧状态保护字段(LLM 生成失败场景的防回归)。
516    /// 构造带 protected_docs/doc_fingerprints 的旧状态 → 跑 FileWatch 增量
517    /// (其内部 from_insights + preserve_protection 后落盘)→ 断言磁盘状态
518    /// 保护字段仍在(生成失败后下次运行保护不丢)。
519    #[test]
520    fn test_file_watch_midway_save_preserves_protection() {
521        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_watch_protect_{}", std::process::id()));
522        let _ = std::fs::remove_dir_all(&dir);
523        let src = dir.join("src");
524        std::fs::create_dir_all(&src).unwrap();
525        std::fs::write(src.join("a.rs"), "v1").unwrap();
526
527        let insight = make_insight(src.join("a.rs").to_string_lossy().as_ref());
528        let state_dir = dir.join(".state");
529
530        // 旧状态:带人工保护(模拟上次生成完成后的状态)
531        let root = crate::project::ProjectRoot::new(dir.clone());
532        let mut old = GenerationState::from_insights(&root, std::slice::from_ref(&insight), "old").unwrap();
533        old.protected_docs = vec!["wiki/zh/manual.md".to_string()];
534        old.doc_fingerprints = std::collections::HashMap::from([("wiki/zh/manual.md".to_string(), "fp".to_string())]);
535        old.save(&state_dir).unwrap();
536
537        // 触发 FileWatch 增量(内容变更 → 中途存盘路径执行)
538        std::fs::write(src.join("a.rs"), "v2").unwrap();
539        let changed = src.join("b.rs");
540        let graph = KnowledgeGraph::default();
541        run_file_watch_incremental(&root, std::slice::from_ref(&insight), &graph, &state_dir, std::slice::from_ref(&changed)).unwrap();
542
543        // 磁盘状态必须保留保护字段(中途失败后人工修改保护不失效)
544        let saved = GenerationState::load(&state_dir).unwrap();
545        assert_eq!(saved.protected_docs, vec!["wiki/zh/manual.md"], "中途存盘不得清空保护集");
546        assert_eq!(saved.doc_fingerprints.get("wiki/zh/manual.md").map(String::as_str), Some("fp"));
547
548        let _ = std::fs::remove_dir_all(&dir);
549    }
550
551    /// A1:全新仓库首次 update(无基线状态)不得静默短路——
552    /// 空 diff 时应回退全量生成,避免首用产出空 wiki
553    #[test]
554    fn test_first_update_no_baseline_falls_back_full() {
555        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_first_update_{}", std::process::id()));
556        let _ = std::fs::remove_dir_all(&dir);
557        let repo = git2::Repository::init(&dir).unwrap();
558        let mut cfg = repo.config().unwrap();
559        cfg.set_str("user.name", "test").unwrap();
560        cfg.set_str("user.email", "test@test.com").unwrap();
561
562        // 单 commit 仓库(HEAD 无父):无基线时 diff 为空
563        std::fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
564        let mut index = repo.index().unwrap();
565        index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
566        index.write().unwrap();
567        let tree_id = index.write_tree().unwrap();
568        let tree = repo.find_tree(tree_id).unwrap();
569        let sig = git2::Signature::now("test", "test@test.com").unwrap();
570        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
571
572        let insights = vec![make_insight("a.rs")];
573        let graph = KnowledgeGraph::default();
574        let state_dir = dir.join(".state");
575
576        let (changed, _affected, _entity_changes, _has_deleted) =
577            run_file_watch_incremental(&ProjectRoot::new(dir.clone()), &insights, &graph, &state_dir, &[]).unwrap();
578        assert_eq!(
579            changed,
580            vec![PathBuf::from("a.rs")],
581            "首次 update 无基线时应回退全量(changed_files = 全部源文件),不得静默跳过: {:?}",
582            changed
583        );
584
585        let _ = std::fs::remove_dir_all(&dir);
586    }
587
588    /// A1 配套:有基线 + 无变更 → 正常跳过(不被回退全量误伤)
589    #[test]
590    fn test_with_baseline_no_change_skips() {
591        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_skip_nochange_{}", std::process::id()));
592        let _ = std::fs::remove_dir_all(&dir);
593        let repo = git2::Repository::init(&dir).unwrap();
594        let mut cfg = repo.config().unwrap();
595        cfg.set_str("user.name", "test").unwrap();
596        cfg.set_str("user.email", "test@test.com").unwrap();
597
598        std::fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
599        let mut index = repo.index().unwrap();
600        index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
601        index.write().unwrap();
602        let tree_id = index.write_tree().unwrap();
603        let tree = repo.find_tree(tree_id).unwrap();
604        let sig = git2::Signature::now("test", "test@test.com").unwrap();
605        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
606        let head_hash = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
607
608        let insights = vec![make_insight("a.rs")];
609        let graph = KnowledgeGraph::default();
610        let state_dir = dir.join(".state");
611
612        // 基线 = 当前 HEAD:diff 为空 → 应跳过(changed_files 为空)
613        let baseline = GenerationState::from_insights(
614            &crate::project::ProjectRoot::new(dir.clone()),
615            &insights,
616            &head_hash,
617        )
618        .unwrap();
619        baseline.save(&state_dir).unwrap();
620
621        let (changed, _affected, _entity_changes, _has_deleted) =
622            run_file_watch_incremental(&ProjectRoot::new(dir.clone()), &insights, &graph, &state_dir, &[]).unwrap();
623        assert!(changed.is_empty(), "有基线且无变更时应跳过,不得回退全量: {:?}", changed);
624
625        let _ = std::fs::remove_dir_all(&dir);
626    }
627
628    ///(返回全部文件集),而非日志声称全量却返回空变更集导致跳过
629    /// A6:状态文件损坏(load 失败)时不 panic、按无基线回退全量,
630    /// 且不静默(warn 由 tracing 输出,行为断言为回退全量)
631    #[test]
632    fn test_corrupt_state_falls_back_full() {
633        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_corrupt_state_{}", std::process::id()));
634        let _ = std::fs::remove_dir_all(&dir);
635        let repo = git2::Repository::init(&dir).unwrap();
636        let mut cfg = repo.config().unwrap();
637        cfg.set_str("user.name", "test").unwrap();
638        cfg.set_str("user.email", "test@test.com").unwrap();
639
640        std::fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
641        let mut index = repo.index().unwrap();
642        index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
643        index.write().unwrap();
644        let tree_id = index.write_tree().unwrap();
645        let tree = repo.find_tree(tree_id).unwrap();
646        let sig = git2::Signature::now("test", "test@test.com").unwrap();
647        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
648
649        // 写损坏的状态 JSON
650        let state_dir = dir.join(".state");
651        std::fs::create_dir_all(&state_dir).unwrap();
652        std::fs::write(state_dir.join("generation_state.json"), "{ 这不是合法 JSON").unwrap();
653
654        let insights = vec![make_insight("a.rs")];
655        let graph = KnowledgeGraph::default();
656
657        let (changed, _affected, _entity_changes, _has_deleted) =
658            run_file_watch_incremental(&ProjectRoot::new(dir.clone()), &insights, &graph, &state_dir, &[]).unwrap();
659        assert_eq!(
660            changed,
661            vec![PathBuf::from("a.rs")],
662            "状态损坏应按无基线回退全量(保守:宁可全量也不产出空产物): {:?}",
663            changed
664        );
665
666        let _ = std::fs::remove_dir_all(&dir);
667    }
668
669    // ==================== v19 t06:no-op 快速跳过 ====================
670
671    /// 构造带 config 的 git 仓库 + 首个 commit,返回 (目录, 首个 HEAD hash, config)。
672    /// repo 用完即弃(借用生命周期的元组约束),需要 repo 的用例自行 reopen。
673    /// 目录带原子计数后缀:cargo test 并行跑多个用例,共用目录会竞争 .git/config.lock。
674    fn setup_noop_fixture() -> (PathBuf, String, WikiConfig) {
675        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
676        let dir = std::env::temp_dir().join(format!(
677            "code_repo_wiki_test_noop_{}_{}",
678            std::process::id(),
679            SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
680        ));
681        let _ = std::fs::remove_dir_all(&dir);
682        let repo = git2::Repository::init(&dir).unwrap();
683        let mut cfg = repo.config().unwrap();
684        cfg.set_str("user.name", "test").unwrap();
685        cfg.set_str("user.email", "test@test.com").unwrap();
686
687        std::fs::create_dir_all(dir.join("src")).unwrap();
688        std::fs::write(dir.join("src").join("a.rs"), "fn a() {}\n").unwrap();
689        let mut index = repo.index().unwrap();
690        index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
691        index.write().unwrap();
692        let tree_id = index.write_tree().unwrap();
693        let tree = repo.find_tree(tree_id).unwrap();
694        let sig = git2::Signature::now("test", "test@test.com").unwrap();
695        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
696        let head = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
697
698        let mut config = make_config();
699        config.output_dir = Some(dir.join("out"));
700        (dir, head, config)
701    }
702
703    /// 保存生成状态(基线 = 指定 commit)+ 建产物信号目录
704    fn save_baseline(dir: &Path, config: &WikiConfig, commit_hash: &str) {
705        let insights = vec![make_insight("src/a.rs")];
706        let state = GenerationState::from_insights(
707            &ProjectRoot::new(dir.to_path_buf()),
708            &insights,
709            commit_hash,
710        )
711        .unwrap();
712        let state_dir = config.output_dir().join(".state");
713        state.save(&state_dir).unwrap();
714        std::fs::create_dir_all(config.output_dir().join("wiki").join("zh")).unwrap();
715    }
716
717    /// head 相同 + 工作树干净 + 产物存在 → 跳过(true)
718    #[test]
719    fn test_noop_skip_when_head_matches_and_clean() {
720        let (dir, head, config) = setup_noop_fixture();
721        save_baseline(&dir, &config, &head);
722
723        assert!(
724            should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
725            "head 相同 + 工作树干净 + 产物存在应跳过"
726        );
727        let _ = std::fs::remove_dir_all(&dir);
728    }
729
730    /// head 变化(新提交)→ 不跳过(false)
731    #[test]
732    fn test_noop_no_skip_when_head_changed() {
733        let (dir, first, config) = setup_noop_fixture();
734        // 基线 = 第一 commit,随后提交第二个 commit → head 变化
735        save_baseline(&dir, &config, &first);
736
737        let repo = git2::Repository::open(&dir).unwrap();
738        std::fs::write(dir.join("src").join("a.rs"), "fn a() { println!(); }\n").unwrap();
739        let mut index = repo.index().unwrap();
740        index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
741        index.write().unwrap();
742        let tree_id = index.write_tree().unwrap();
743        let tree = repo.find_tree(tree_id).unwrap();
744        let sig = git2::Signature::now("test", "test@test.com").unwrap();
745        repo.commit(Some("HEAD"), &sig, &sig, "second", &tree, &[&repo.head().unwrap().peel_to_commit().unwrap()]).unwrap();
746
747        assert!(
748            !should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
749            "有新提交不得跳过"
750        );
751        let _ = std::fs::remove_dir_all(&dir);
752    }
753
754    /// head 相同但工作树有源码未提交变更 → 不跳过;
755    /// 产物目录(output_dir)内的变更 → 仍跳过(不算源码变更)
756    #[test]
757    fn test_noop_status_scope_filtering() {
758        let (dir, head, config) = setup_noop_fixture();
759        save_baseline(&dir, &config, &head);
760
761        // 源码(src/)未提交变更 → 不跳过
762        std::fs::write(dir.join("src").join("a.rs"), "fn a() { /* dirty */ }\n").unwrap();
763        assert!(
764            !should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
765            "源码未提交变更不得跳过"
766        );
767        // 还原后产物目录内出现新文件 → 仍跳过(产物不算源码变更)
768        std::fs::write(dir.join("src").join("a.rs"), "fn a() {}\n").unwrap();
769        std::fs::write(
770            config.output_dir().join("out.txt"),
771            "not code",
772        )
773        .unwrap();
774        assert!(
775            should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
776            "产物目录变更不应阻断跳过"
777        );
778        let _ = std::fs::remove_dir_all(&dir);
779    }
780
781    /// 产物目录缺失 → 不跳过(no-op 固有盲区:产物被删不得保持缺失)
782    #[test]
783    fn test_noop_no_skip_when_output_missing() {
784        let (dir, head, config) = setup_noop_fixture();
785        // 只存基线,不建产物目录
786        let insights = vec![make_insight("src/a.rs")];
787        let state = GenerationState::from_insights(
788            &ProjectRoot::new(dir.clone()),
789            &insights,
790            &head,
791        )
792        .unwrap();
793        let state_dir = config.output_dir().join(".state");
794        state.save(&state_dir).unwrap();
795
796        assert!(
797            !should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
798            "产物目录缺失不得跳过"
799        );
800        let _ = std::fs::remove_dir_all(&dir);
801    }
802
803    /// 无基线(状态缺失/无 last_commit_hash)→ 不跳过(首次 update 走正常路径)
804    #[test]
805    fn test_noop_no_skip_without_baseline() {
806        let (dir, _head, config) = setup_noop_fixture();
807        assert!(
808            !should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
809            "无基线状态不得跳过"
810        );
811        let _ = std::fs::remove_dir_all(&dir);
812    }
813
814    /// v22 失败补偿重试:状态含 failed_modules 且 git 无变更时,
815    /// 失败模块的存活文件(从导出快照 cards.related_files 解析)并入
816    /// changed_files(下次 update 补生成),no-op 快速判定同时放行。
817    #[test]
818    fn test_incremental_merges_failed_modules_from_state() {
819        let dir = std::env::temp_dir()
820            .join(format!("code_repo_wiki_test_failed_retry_{}", std::process::id()));
821        let _ = std::fs::remove_dir_all(&dir);
822        let root = ProjectRoot::new(dir.clone());
823        std::fs::create_dir_all(dir.join("src").join("m20")).unwrap();
824
825        // git 仓库:一个提交(a.rs/b.rs),此后无任何变更
826        let repo = git2::Repository::init(&dir).unwrap();
827        let mut cfg = repo.config().unwrap();
828        cfg.set_str("user.name", "test").unwrap();
829        cfg.set_str("user.email", "test@test.com").unwrap();
830        std::fs::write(dir.join("src").join("m20").join("a.rs"), "pub fn fa() {}\n").unwrap();
831        std::fs::write(dir.join("src").join("m20").join("b.rs"), "pub fn fb() {}\n").unwrap();
832        let mut index = repo.index().unwrap();
833        index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
834        index.write().unwrap();
835        let tree_id = index.write_tree().unwrap();
836        let tree = repo.find_tree(tree_id).unwrap();
837        let sig = git2::Signature::now("test", "test@test.com").unwrap();
838        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
839        let head = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
840
841        // 状态:基线 = 当前 HEAD,failed_modules = ["src::m20"](社区名体系)
842        let state_dir = dir.join(".state");
843        let mut state = GenerationState::from_insights(&root, &[], &head).unwrap();
844        state.failed_modules = vec!["src::m20".into()];
845        state.save(&state_dir).unwrap();
846
847        // 导出快照:src::m20 卡片携带 related_files(与卡片 module_name 同体系)
848        let snapshot = crate::output::ExportSnapshot {
849            version: 1,
850            documents: vec![],
851            cards: vec![crate::model::KnowledgeCard {
852                module_name: "src::m20".into(),
853                module_type: "module".into(),
854                summary: String::new(),
855                key_entities: vec![],
856                dependencies: vec![],
857                dependents: vec![],
858                design_patterns: vec![],
859                todo_notes: vec![],
860                related_files: vec!["src/m20/a.rs".into(), "src/m20/b.rs".into()],
861                coding_spec: None,
862                tech_stack: vec![],
863                architecture: None,
864                pending_manual_edits: vec![],
865                features: vec![],
866            }],
867            modules: vec![],
868        };
869        std::fs::write(
870            dir.join(".state").join("export_snapshot.json"),
871            serde_json::to_string(&snapshot).unwrap(),
872        )
873        .unwrap();
874
875        // git 无变更:changed_files 应为空 → 补偿并入失败模块存活文件
876        // (output.dir 指向 dir,与 save 状态目录一致)
877        let mut config = make_config();
878        config.output_dir = Some(dir.to_path_buf());
879        let insights = vec![make_insight("src/m20/a.rs"), make_insight("src/m20/b.rs")];
880        let graph = KnowledgeGraph::default();
881        let result = run_incremental_update_at(&root, &insights, &graph, &config, &[]).unwrap();
882        assert!(
883            result.changed_files.contains(&PathBuf::from("src/m20/a.rs")),
884            "失败模块的存活文件必须并入变更集(补生成)"
885        );
886        assert!(
887            result.changed_files.contains(&PathBuf::from("src/m20/b.rs")),
888            "同模块全部存活文件都并入"
889        );
890
891        // no-op 判据:failed_modules 非空 → 不跳过(补偿重试必须可达)
892        assert!(
893            !should_skip_noop(&root, &config).unwrap(),
894            "存在失败模块时 no-op 快速判定不得跳过"
895        );
896
897        let _ = std::fs::remove_dir_all(&dir);
898    }
899}