3838from ..i18n import t
3939
4040
41+ def _detect_existing_features (target_path : Path ) -> dict [str , bool ]:
42+ """Reconstruct ``include_*`` and sub-feature flags from project structure.
43+
44+ Older template versions didn't have today's full set of questions in
45+ ``copier.yml`` (e.g. ``include_insights`` was added after 0.6.10), so
46+ those flags are missing from older ``.copier-answers.yml`` files.
47+ During ``aegis update -y``, copier silently uses the template's
48+ ``default: false`` for any missing flag — which means a project that
49+ *clearly* has ``app/services/insights/`` on disk gets re-rendered as
50+ if it never had insights, deleting service files and breaking the
51+ project.
52+
53+ To prevent that, we walk the project structure and re-derive a flag
54+ set from what's actually installed. The caller persists those inferred
55+ values into ``.copier-answers.yml`` BEFORE invoking copier update, so
56+ copier reads them as part of the project's stored answers instead of
57+ falling back to template defaults for newly-added questions. Writing
58+ them to the answers file (rather than passing via
59+ ``run_update(data=...)``) means every downstream step in the same
60+ update run — copier render, ``cleanup_components``,
61+ ``sync_template_changes``, ``run_post_generation_tasks`` — sees one
62+ consistent picture. Earlier iterations of this fix passed the flags
63+ only via ``data=`` and ``cleanup_components`` then re-read the stale
64+ answers and deleted service files anyway.
65+ """
66+ app = target_path / "app"
67+
68+ # Service directory presence → include_<service>
69+ service_flags = {
70+ "include_auth" : app / "services" / "auth" ,
71+ "include_ai" : app / "services" / "ai" ,
72+ "include_comms" : app / "services" / "comms" ,
73+ "include_insights" : app / "services" / "insights" ,
74+ "include_payment" : app / "services" / "payment" ,
75+ }
76+
77+ # Component directory/file presence → include_<component>
78+ component_flags = {
79+ "include_database" : app / "core" / "db.py" ,
80+ "include_redis" : app / "components" / "redis" ,
81+ "include_worker" : app / "components" / "worker" ,
82+ "include_scheduler" : app / "components" / "scheduler" ,
83+ }
84+
85+ detected : dict [str , bool ] = {}
86+ for flag , path in {** service_flags , ** component_flags }.items ():
87+ if path .exists ():
88+ detected [flag ] = True
89+
90+ # Insights sub-flags — the collector files alone aren't a reliable
91+ # signal because older template versions shipped them all
92+ # unconditionally. The actual signal of "this source is wired up" is
93+ # whether ``collector_service.py`` registers it. Using that here means
94+ # we won't resurrect collectors the user never opted into AND we won't
95+ # tear down collectors they actively use.
96+ collector_service = app / "services" / "insights" / "collector_service.py"
97+ if collector_service .exists ():
98+ service_src = collector_service .read_text ()
99+ detected ["insights_github" ] = "GitHubTrafficCollector" in service_src
100+ detected ["insights_pypi" ] = "PyPICollector" in service_src
101+ detected ["insights_plausible" ] = "PlausibleCollector" in service_src
102+ detected ["insights_reddit" ] = "RedditCollector" in service_src
103+
104+ return detected
105+
106+
41107def _get_template_changed_files (
42108 template_root : Path ,
43109 from_ref : str ,
@@ -358,6 +424,88 @@ def update_command(
358424 target_ref ,
359425 )
360426
427+ # Persist feature flags inferred from project structure into the
428+ # answers file BEFORE copier runs. Older answers files are missing
429+ # questions that were added later (e.g. ``include_insights`` was
430+ # added after 0.6.10), and ``defaults=True`` would silently use the
431+ # template's ``default: false`` for those — wiping service files
432+ # the user is actively using. By writing the inferred flags into
433+ # ``.copier-answers.yml`` first, every downstream step (copier
434+ # render, cleanup_components, sync_template_changes,
435+ # post_generation_tasks) sees the correct state.
436+ # See ``_detect_existing_features`` for full reasoning + scope.
437+ detected_flags = _detect_existing_features (target_path )
438+ if detected_flags :
439+ answers_path = target_path / ".copier-answers.yml"
440+ if answers_path .exists ():
441+ with open (answers_path ) as f :
442+ current_answers = yaml .safe_load (f ) or {}
443+ # setdefault: only fill in MISSING flags. Don't overwrite an
444+ # explicit ``False`` from a user who deliberately removed
445+ # a service.
446+ changed = False
447+ for flag , value in detected_flags .items ():
448+ if flag not in current_answers :
449+ current_answers [flag ] = value
450+ changed = True
451+ if changed :
452+ with open (answers_path , "w" ) as f :
453+ yaml .safe_dump (
454+ current_answers ,
455+ f ,
456+ default_flow_style = False ,
457+ sort_keys = False ,
458+ )
459+ # Copier requires a clean git tree, so commit the
460+ # backfill. If the commit fails (e.g. blocked by a
461+ # pre-commit hook) the working tree is left dirty —
462+ # which would cause copier to fail with a confusing
463+ # error several steps later. Verify the tree is clean
464+ # post-commit and abort with a clear message if not.
465+ try :
466+ subprocess .run (
467+ ["git" , "add" , ".copier-answers.yml" ],
468+ cwd = target_path ,
469+ check = True ,
470+ capture_output = True ,
471+ )
472+ subprocess .run (
473+ [
474+ "git" ,
475+ "commit" ,
476+ "-m" ,
477+ "Backfill missing copier flags from project structure" ,
478+ ],
479+ cwd = target_path ,
480+ check = True ,
481+ capture_output = True ,
482+ )
483+ except subprocess .CalledProcessError as exc :
484+ # ``git commit`` exits non-zero when there's
485+ # nothing to commit too — that's harmless. Only
486+ # abort if the answers file is actually dirty.
487+ status = subprocess .run (
488+ ["git" , "status" , "--porcelain" , ".copier-answers.yml" ],
489+ cwd = target_path ,
490+ capture_output = True ,
491+ text = True ,
492+ )
493+ if status .stdout .strip ():
494+ stderr = (
495+ (exc .stderr or b"" )
496+ .decode ("utf-8" , errors = "replace" )
497+ .strip ()
498+ )
499+ typer .secho (
500+ "Failed to commit backfilled .copier-answers.yml; "
501+ "aborting because copier requires a clean git tree." ,
502+ fg = "red" ,
503+ err = True ,
504+ )
505+ if stderr :
506+ typer .echo (stderr , err = True )
507+ raise typer .Exit (1 ) from exc
508+
361509 # Run Copier update with git-aware merge
362510 # NOTE: We do NOT pass src_path - Copier reads it from .copier-answers.yml
363511 # This is critical for Copier's git tracking detection to work correctly
@@ -374,6 +522,9 @@ def update_command(
374522 typer .echo (t ("update.updating_to" , version = target_version_display ))
375523
376524 # Load answers for cleanup and post-generation tasks
525+ # (the answers file was already backfilled with detected flags
526+ # above, so it has every ``include_*`` value cleanup_components
527+ # needs to make correct decisions.)
377528 answers = load_copier_answers (target_path )
378529
379530 # Clean up nested directory if Copier created one
0 commit comments