@@ -186,6 +186,17 @@ async def _run_backend_generation(self, backend_config: BackendConfig):
186186 # Create documentation generator
187187 doc_generator = DocumentationGenerator (backend_config , commit_id = self .commit_id )
188188
189+ # Incremental update (component-level): keep the previous graph before
190+ # the builder overwrites it, so the updater can diff old against new.
191+ update_opts = self ._update_options ()
192+ prev_graph_path = None
193+ if update_opts is not None :
194+ from codewiki .src .be .updater .graph_store import snapshot_old_graph
195+
196+ prev_graph_path = snapshot_old_graph (
197+ backend_config .dependency_graph_dir , str (self .repo_path )
198+ )
199+
189200 if self .verbose :
190201 self .progress_tracker .update_stage (0.5 , "Parsing source files..." )
191202
@@ -211,6 +222,27 @@ async def _run_backend_generation(self, backend_config: BackendConfig):
211222
212223 self .progress_tracker .complete_stage ()
213224
225+ if update_opts is not None :
226+ outcome = await self ._run_incremental_update (
227+ backend_config , doc_generator , update_opts , prev_graph_path , components , leaf_nodes
228+ )
229+ if outcome in ("incremental" , "no_change" ):
230+ # The builder wrote the new graph under this checkout's name. Drop graphs
231+ # left by earlier checkouts so the next update has exactly one to pick.
232+ from codewiki .src .be .updater .graph_store import (
233+ graph_file_path ,
234+ prune_superseded_graphs ,
235+ )
236+
237+ prune_superseded_graphs (
238+ backend_config .dependency_graph_dir ,
239+ keep = graph_file_path (backend_config .dependency_graph_dir , str (self .repo_path )),
240+ )
241+ return
242+ # full_fallback / detector_failure: preserve the old docs, rebuild from scratch
243+ self ._move_docs_aside ()
244+ components , leaf_nodes = doc_generator .graph_builder .build_dependency_graph ()
245+
214246 # Stage 2: Module Clustering
215247 self .progress_tracker .start_stage (2 , "Module Clustering" )
216248 if self .verbose :
@@ -334,6 +366,7 @@ async def _run_backend_generation(self, backend_config: BackendConfig):
334366
335367 # Create metadata
336368 doc_generator .create_documentation_metadata (working_dir , components , len (leaf_nodes ))
369+ self ._merge_update_summary (working_dir )
337370
338371 # Collect generated files
339372 for file_path in os .listdir (working_dir ):
@@ -356,6 +389,111 @@ async def _run_backend_generation(self, backend_config: BackendConfig):
356389
357390 self .progress_tracker .complete_stage ()
358391
392+ # ------------------------------------------------------------------
393+ # Incremental update helpers
394+ # ------------------------------------------------------------------
395+
396+ def _update_options (self ):
397+ """Return ``UpdateOptions`` when this run is a component-level update, else None."""
398+ if not self .config .get ("update" ):
399+ return None
400+ raw = dict (self .config .get ("update_options" ) or {})
401+ rung = str (raw .pop ("rung" , "3" ) or "3" )
402+ if rung == "0" :
403+ return None # legacy path handled in generate.py
404+ if not (self .output_dir / "module_tree.json" ).exists ():
405+ return None # nothing to update against: normal generation
406+ from codewiki .src .be .updater .options import UpdateOptions
407+
408+ return UpdateOptions .from_rung (rung , ** raw )
409+
410+ async def _run_incremental_update (
411+ self , backend_config , doc_generator , update_opts , prev_graph_path , components , leaf_nodes
412+ ) -> str :
413+ import json
414+
415+ from codewiki .src .be .updater .orchestrator import IncrementalUpdater
416+
417+ self .progress_tracker .start_stage (2 , "Incremental Update" )
418+ prev_commit = None
419+ try :
420+ with open (self .output_dir / "metadata.json" , encoding = "utf-8" ) as f :
421+ prev_commit = (json .load (f ).get ("generation_info" ) or {}).get ("commit_id" )
422+ except (OSError , json .JSONDecodeError , AttributeError ):
423+ pass
424+ revision = {
425+ "old_commit" : prev_commit ,
426+ "new_commit" : self .commit_id ,
427+ "repo_path" : str (self .repo_path ),
428+ }
429+ updater = IncrementalUpdater (
430+ backend_config , doc_generator .backend , doc_generator , update_opts
431+ )
432+ record = await updater .run (prev_graph_path , components , leaf_nodes , revision )
433+ self ._last_update_record = record
434+ summary = record .summary ()
435+ if self .verbose :
436+ self .progress_tracker .update_stage (
437+ 0.9 ,
438+ f"Update outcome: { record .outcome } (diff { summary ['diff_counts' ]} , "
439+ f"{ summary ['n_active' ]} active leaves, { summary ['n_calls' ]} LLM calls)" ,
440+ )
441+ working_dir = str (self .output_dir .absolute ())
442+ if record .outcome in ("incremental" , "no_change" ):
443+ doc_generator .create_documentation_metadata (working_dir , components , len (leaf_nodes ))
444+ self ._merge_update_summary (working_dir )
445+ for file_path in os .listdir (working_dir ):
446+ if file_path .endswith ((".md" , ".json" )):
447+ self .job .files_generated .append (file_path )
448+ tree_path = os .path .join (working_dir , "module_tree.json" )
449+ if os .path .exists (tree_path ):
450+ with open (tree_path , encoding = "utf-8" ) as f :
451+ self .job .module_count = len (json .load (f ))
452+ missing_docs = doc_generator .validate_generated_docs (working_dir )
453+ if missing_docs :
454+ raise IncompleteGenerationError (
455+ "Incremental update finished but these module docs are missing: "
456+ + ", " .join (f"{ name } .md" for name in missing_docs ),
457+ missing_modules = missing_docs ,
458+ )
459+ self .progress_tracker .complete_stage ()
460+ return record .outcome
461+
462+ def _move_docs_aside (self ) -> None :
463+ """Keep the previous docs (and the failed attempt's record) next to the output dir."""
464+ import shutil
465+
466+ suffix = (self .commit_id or "unknown" )[:8 ]
467+ target = self .output_dir .parent / f"{ self .output_dir .name } .prev-{ suffix } "
468+ n = 2
469+ while target .exists ():
470+ target = self .output_dir .parent / f"{ self .output_dir .name } .prev-{ suffix } -{ n } "
471+ n += 1
472+ shutil .move (str (self .output_dir ), str (target ))
473+ self .output_dir .mkdir (parents = True , exist_ok = True )
474+ self ._preserved_docs_dir = str (target )
475+ record = getattr (self , "_last_update_record" , None )
476+ if record is not None :
477+ record .detector_notes .append (f"previous docs moved to { target } " )
478+ if self .verbose :
479+ self .progress_tracker .update_stage (0.1 , f"Previous docs preserved at { target } " )
480+
481+ def _merge_update_summary (self , working_dir : str ) -> None :
482+ record = getattr (self , "_last_update_record" , None )
483+ if record is None :
484+ return
485+ from codewiki .src .be .updater .record import merge_into_metadata
486+
487+ summary = record .summary ()
488+ preserved = getattr (self , "_preserved_docs_dir" , None )
489+ if preserved :
490+ summary ["previous_docs" ] = preserved
491+ merge_into_metadata (working_dir , summary )
492+ try :
493+ record .save (working_dir )
494+ except OSError :
495+ pass
496+
359497 def _run_html_generation (self ):
360498 """Run HTML generation stage."""
361499 self .progress_tracker .start_stage (4 , "HTML Generation" )
0 commit comments