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
20pub fn should_skip_noop(root: &ProjectRoot, config: &WikiConfig) -> anyhow::Result<bool> {
40 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 if !state.failed_modules.is_empty() {
52 return Ok(false);
53 }
54 if !config.output_dir().join("wiki").exists() {
56 return Ok(false);
57 }
58 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 if status_has_source_changes(&repo, config, root) {
72 return Ok(false);
73 }
74 Ok(true)
75}
76
77fn status_has_source_changes(
89 repo: &git2::Repository,
90 config: &WikiConfig,
91 root: &ProjectRoot,
92) -> bool {
93 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 Err(_) => true,
108 }
109}
110
111pub(crate) fn norm_sep(p: &str) -> String {
119 p.replace('\\', "/")
120}
121
122pub struct IncrementalResult {
124 pub changed_files: Vec<std::path::PathBuf>,
126 pub affected_modules: Vec<String>,
128 pub entity_changes: EntityChangeSet,
131 pub has_deleted_files: bool,
140}
141
142pub 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 let state_dir = config.output_dir().join(".state");
159
160 let (changed_files, affected_modules, entity_changes, has_deleted_files) =
162 run_file_watch_incremental(root, insights, graph, &state_dir, watch_paths)?;
163
164 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 tracing::warn!(
200 "失败补偿重试:导出快照不可读({}),跳过补偿并入",
201 snapshot_path.display()
202 );
203 }
204 let mut merged = changed_files.clone();
205 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
224fn 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 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 for p in watch_paths {
257 if !changed_files.contains(p) {
258 changed_files.push(p.clone());
259 }
260 }
261
262 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 let (entity_changes, classification_failed) = if let Some(state) = &state {
287 let rel = |p: &PathBuf| p.strip_prefix(root.path()).unwrap_or(p).to_path_buf();
291 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 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 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 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 #[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 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 let first_hash = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
404
405 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 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 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 #[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 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 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 #[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 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 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 #[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 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 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 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 #[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 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 #[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 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 #[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 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 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 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 #[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 #[test]
732 fn test_noop_no_skip_when_head_changed() {
733 let (dir, first, config) = setup_noop_fixture();
734 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 #[test]
757 fn test_noop_status_scope_filtering() {
758 let (dir, head, config) = setup_noop_fixture();
759 save_baseline(&dir, &config, &head);
760
761 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 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 #[test]
783 fn test_noop_no_skip_when_output_missing() {
784 let (dir, head, config) = setup_noop_fixture();
785 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 #[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 #[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 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 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 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 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 assert!(
893 !should_skip_noop(&root, &config).unwrap(),
894 "存在失败模块时 no-op 快速判定不得跳过"
895 );
896
897 let _ = std::fs::remove_dir_all(&dir);
898 }
899}