Skip to content

Commit 2be217f

Browse files
author
Aegis Stack
committed
v0.5.2-rc5
1 parent 5b5de65 commit 2be217f

8 files changed

Lines changed: 329 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Each generated project includes:
3232

3333
## Installation
3434

35-
**Current Version**: v0.5.2-rc4
35+
**Current Version**: v0.5.2-rc5
3636

3737
```bash
3838
pip install aegis-stack

aegis/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Aegis Stack CLI - Component generation and project management tools.
33
"""
44

5-
__version__ = "0.5.2-rc4"
5+
__version__ = "0.5.2-rc5"

aegis/commands/update.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
validate_clean_git_tree,
3232
)
3333
from ..core.post_gen_tasks import cleanup_components, run_post_generation_tasks
34-
from ..core.template_cleanup import cleanup_nested_project_directory
34+
from ..core.template_cleanup import (
35+
cleanup_nested_project_directory,
36+
sync_template_changes,
37+
)
3538
from ..core.version_compatibility import get_cli_version, get_project_template_version
3639

3740

@@ -335,6 +338,16 @@ def update_command(
335338
# files and cleanup_components removes those not selected in answers
336339
cleanup_components(target_path, answers)
337340

341+
# Sync template changes that Copier's git apply may have missed
342+
# This handles the case where the {{ project_slug }}/ directory is excluded
343+
# from git apply because it only contains ignored files
344+
template_src = answers.get("_src_path", "gh:lbedner/aegis-stack")
345+
synced_files = sync_template_changes(
346+
target_path, answers, template_src, target_ref
347+
)
348+
if synced_files:
349+
typer.echo(f" Synced {len(synced_files)} template changes")
350+
338351
# Run post-generation tasks
339352
# Determine what services need migrations
340353
include_auth = answers.get(AnswerKeys.AUTH, False)

aegis/core/template_cleanup.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import shutil
9+
import tempfile
910
from pathlib import Path
1011

1112
from .verbosity import verbose_print
@@ -90,3 +91,125 @@ def cleanup_nested_project_directory(
9091
verbose_print(f" Warning: Could not remove {project_slug}/")
9192

9293
return files_moved
94+
95+
96+
def sync_template_changes(
97+
project_path: Path,
98+
answers: dict,
99+
template_src: str,
100+
vcs_ref: str,
101+
) -> list[str]:
102+
"""
103+
Sync template changes that Copier's git apply may have missed.
104+
105+
Copier's update mechanism uses `git apply` with exclusions based on
106+
`git status --ignored`. When the template uses a {{ project_slug }}/
107+
wrapper, and the nested directory contains only ignored files, the
108+
entire project slug directory gets excluded from the patch.
109+
110+
This function works around that by:
111+
1. Rendering the new template to a temp directory
112+
2. Comparing files between project and rendered template
113+
3. Updating project files that differ from template
114+
115+
Note: This function only syncs EXISTING files. New files are handled by
116+
cleanup_nested_project_directory() which must run BEFORE this function.
117+
The update command (aegis/commands/update.py) ensures this ordering.
118+
119+
Args:
120+
project_path: Path to project root
121+
answers: Copier answers dict (from .copier-answers.yml)
122+
template_src: Template source (e.g., "gh:user/repo")
123+
vcs_ref: Git ref for template version (e.g., "v0.5.2-rc4")
124+
125+
Returns:
126+
List of relative file paths that were updated
127+
"""
128+
from copier import run_copy
129+
130+
project_slug = answers.get("project_slug", "")
131+
if not project_slug:
132+
return []
133+
134+
files_updated: list[str] = []
135+
136+
# Create temp directory for rendered template
137+
with tempfile.TemporaryDirectory() as temp_dir:
138+
temp_path = Path(temp_dir)
139+
140+
# Render the new template version
141+
try:
142+
run_copy(
143+
src_path=template_src,
144+
dst_path=str(temp_path),
145+
data=answers,
146+
defaults=True,
147+
overwrite=True,
148+
unsafe=False,
149+
vcs_ref=vcs_ref,
150+
quiet=True,
151+
)
152+
except Exception as e:
153+
verbose_print(f" Warning: Could not render template for sync: {e}")
154+
return []
155+
156+
# The rendered template is in temp_path/project_slug/
157+
rendered_dir = temp_path / project_slug
158+
if not rendered_dir.exists():
159+
return []
160+
161+
# Compare and sync files
162+
for template_file in rendered_dir.rglob("*"):
163+
if template_file.is_dir():
164+
continue
165+
166+
# Skip certain files that shouldn't be synced
167+
relative = template_file.relative_to(rendered_dir)
168+
if _should_skip_sync(str(relative)):
169+
continue
170+
171+
project_file = project_path / relative
172+
173+
# Only update existing files (new files handled by cleanup_nested)
174+
if not project_file.exists():
175+
continue
176+
177+
# Compare file contents
178+
try:
179+
template_content = template_file.read_bytes()
180+
project_content = project_file.read_bytes()
181+
182+
if template_content != project_content:
183+
# Update project file with template version
184+
project_file.write_bytes(template_content)
185+
files_updated.append(str(relative))
186+
verbose_print(f" Synced: {relative}")
187+
except OSError as e:
188+
verbose_print(f" Warning: Could not sync {relative}: {e}")
189+
190+
return files_updated
191+
192+
193+
def _should_skip_sync(relative_path: str) -> bool:
194+
"""Check if a file should be skipped during template sync."""
195+
skip_patterns = [
196+
".copier-answers.yml",
197+
".env",
198+
".python-version",
199+
".venv/",
200+
"__pycache__/",
201+
".git/",
202+
"*.pyc",
203+
]
204+
205+
for pattern in skip_patterns:
206+
if pattern.endswith("/"):
207+
if relative_path.startswith(pattern) or f"/{pattern}" in relative_path:
208+
return True
209+
elif pattern.startswith("*"):
210+
if relative_path.endswith(pattern[1:]):
211+
return True
212+
elif relative_path == pattern:
213+
return True
214+
215+
return False

copier.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# - Update support
77

88
_min_copier_version: "9.0.0"
9-
_version: "0.5.2-rc4"
9+
_version: "0.5.2-rc5"
1010

1111
# IMPORTANT: Template content is in subdirectory
1212
# This allows the template to be recognized as git-tracked (aegis-stack repo root has .git)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "aegis-stack"
3-
version = "0.5.2-rc4"
3+
version = "0.5.2-rc5"
44
description = "A production-ready Python foundation for builders who refuse to wait. Try: uvx aegis-stack init my-project"
55
readme = "README.md"
66
requires-python = ">=3.11,<3.15"

tests/core/test_template_cleanup.py

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from pathlib import Path
99
from unittest.mock import patch
1010

11-
from aegis.core.template_cleanup import cleanup_nested_project_directory
11+
from aegis.core.template_cleanup import (
12+
_should_skip_sync,
13+
cleanup_nested_project_directory,
14+
sync_template_changes,
15+
)
1216

1317

1418
class TestCleanupNestedProjectDirectory:
@@ -346,3 +350,185 @@ def test_full_update_flow_with_ai_enabled(self, tmp_path: Path) -> None:
346350
assert (tmp_path / "app" / "services" / "ai" / "agent.py").exists(), (
347351
"AI agent.py should be kept"
348352
)
353+
354+
355+
class TestShouldSkipSync:
356+
"""Test _should_skip_sync helper function."""
357+
358+
def test_skips_copier_answers(self) -> None:
359+
"""Test that .copier-answers.yml is skipped."""
360+
assert _should_skip_sync(".copier-answers.yml") is True
361+
362+
def test_skips_env_file(self) -> None:
363+
"""Test that .env is skipped."""
364+
assert _should_skip_sync(".env") is True
365+
366+
def test_skips_python_version(self) -> None:
367+
"""Test that .python-version is skipped."""
368+
assert _should_skip_sync(".python-version") is True
369+
370+
def test_skips_venv_directory(self) -> None:
371+
"""Test that .venv/ files are skipped."""
372+
assert _should_skip_sync(".venv/lib/python3.11/site-packages/foo.py") is True
373+
374+
def test_skips_pycache(self) -> None:
375+
"""Test that __pycache__/ files are skipped."""
376+
assert _should_skip_sync("__pycache__/module.cpython-311.pyc") is True
377+
assert _should_skip_sync("app/__pycache__/foo.pyc") is True
378+
379+
def test_skips_pyc_files(self) -> None:
380+
"""Test that .pyc files are skipped."""
381+
assert _should_skip_sync("module.pyc") is True
382+
assert _should_skip_sync("app/services/ai/agent.pyc") is True
383+
384+
def test_does_not_skip_regular_files(self) -> None:
385+
"""Test that regular Python files are not skipped."""
386+
assert _should_skip_sync("app/__init__.py") is False
387+
assert _should_skip_sync("app/services/ai/agent.py") is False
388+
assert _should_skip_sync("pyproject.toml") is False
389+
assert _should_skip_sync("app/components/frontend/theme.py") is False
390+
391+
392+
class TestSyncTemplateChanges:
393+
"""Test sync_template_changes function."""
394+
395+
def test_empty_project_slug_returns_empty(self, tmp_path: Path) -> None:
396+
"""Test that empty list is returned when project_slug is empty."""
397+
answers: dict[str, str] = {"project_slug": ""}
398+
result = sync_template_changes(tmp_path, answers, "gh:test/repo", "v1.0.0")
399+
assert result == []
400+
401+
def test_syncs_differing_files(self, tmp_path: Path) -> None:
402+
"""Test that files differing from template are synced."""
403+
project_slug = "my-project"
404+
answers = {"project_slug": project_slug}
405+
406+
# Create project file with old content
407+
project_file = tmp_path / "app" / "config.py"
408+
project_file.parent.mkdir(parents=True)
409+
project_file.write_text("# old config")
410+
411+
def mock_run_copy(
412+
src_path: str,
413+
dst_path: str,
414+
data: dict,
415+
defaults: bool,
416+
overwrite: bool,
417+
unsafe: bool,
418+
vcs_ref: str,
419+
quiet: bool,
420+
) -> None:
421+
"""Mock run_copy to create rendered template."""
422+
rendered_dir = Path(dst_path) / project_slug / "app"
423+
rendered_dir.mkdir(parents=True)
424+
(rendered_dir / "config.py").write_text("# new config from template")
425+
426+
with patch("copier.run_copy", side_effect=mock_run_copy):
427+
result = sync_template_changes(tmp_path, answers, "gh:test/repo", "v1.0.0")
428+
429+
# File should be synced
430+
assert "app/config.py" in result
431+
assert project_file.read_text() == "# new config from template"
432+
433+
def test_skips_identical_files(self, tmp_path: Path) -> None:
434+
"""Test that identical files are not synced."""
435+
project_slug = "my-project"
436+
answers = {"project_slug": project_slug}
437+
438+
# Create project file with same content as template
439+
project_file = tmp_path / "app" / "config.py"
440+
project_file.parent.mkdir(parents=True)
441+
project_file.write_text("# same content")
442+
443+
def mock_run_copy(
444+
src_path: str,
445+
dst_path: str,
446+
data: dict,
447+
defaults: bool,
448+
overwrite: bool,
449+
unsafe: bool,
450+
vcs_ref: str,
451+
quiet: bool,
452+
) -> None:
453+
"""Mock run_copy to create rendered template with same content."""
454+
rendered_dir = Path(dst_path) / project_slug / "app"
455+
rendered_dir.mkdir(parents=True)
456+
(rendered_dir / "config.py").write_text("# same content")
457+
458+
with patch("copier.run_copy", side_effect=mock_run_copy):
459+
result = sync_template_changes(tmp_path, answers, "gh:test/repo", "v1.0.0")
460+
461+
# File should NOT be synced (identical)
462+
assert result == []
463+
464+
def test_skips_nonexistent_project_files(self, tmp_path: Path) -> None:
465+
"""Test that new files in template are skipped (handled by cleanup_nested)."""
466+
project_slug = "my-project"
467+
answers = {"project_slug": project_slug}
468+
469+
# Don't create project file - it doesn't exist
470+
471+
def mock_run_copy(
472+
src_path: str,
473+
dst_path: str,
474+
data: dict,
475+
defaults: bool,
476+
overwrite: bool,
477+
unsafe: bool,
478+
vcs_ref: str,
479+
quiet: bool,
480+
) -> None:
481+
"""Mock run_copy to create new file in template."""
482+
rendered_dir = Path(dst_path) / project_slug / "app"
483+
rendered_dir.mkdir(parents=True)
484+
(rendered_dir / "new_file.py").write_text("# new file")
485+
486+
with patch("copier.run_copy", side_effect=mock_run_copy):
487+
result = sync_template_changes(tmp_path, answers, "gh:test/repo", "v1.0.0")
488+
489+
# New file should NOT be synced (doesn't exist in project)
490+
assert result == []
491+
assert not (tmp_path / "app" / "new_file.py").exists()
492+
493+
def test_skips_files_matching_skip_patterns(self, tmp_path: Path) -> None:
494+
"""Test that files matching skip patterns are not synced."""
495+
project_slug = "my-project"
496+
answers = {"project_slug": project_slug}
497+
498+
# Create .env file in project
499+
env_file = tmp_path / ".env"
500+
env_file.write_text("SECRET=old_value")
501+
502+
def mock_run_copy(
503+
src_path: str,
504+
dst_path: str,
505+
data: dict,
506+
defaults: bool,
507+
overwrite: bool,
508+
unsafe: bool,
509+
vcs_ref: str,
510+
quiet: bool,
511+
) -> None:
512+
"""Mock run_copy to create .env in template."""
513+
rendered_dir = Path(dst_path) / project_slug
514+
rendered_dir.mkdir(parents=True)
515+
(rendered_dir / ".env").write_text("SECRET=new_value")
516+
517+
with patch("copier.run_copy", side_effect=mock_run_copy):
518+
result = sync_template_changes(tmp_path, answers, "gh:test/repo", "v1.0.0")
519+
520+
# .env should NOT be synced (in skip list)
521+
assert result == []
522+
assert env_file.read_text() == "SECRET=old_value"
523+
524+
def test_handles_render_failure(self, tmp_path: Path) -> None:
525+
"""Test that render failure returns empty list."""
526+
answers = {"project_slug": "my-project"}
527+
528+
with patch(
529+
"copier.run_copy",
530+
side_effect=Exception("Render failed"),
531+
):
532+
result = sync_template_changes(tmp_path, answers, "gh:test/repo", "v1.0.0")
533+
534+
assert result == []

0 commit comments

Comments
 (0)