Skip to content

Commit 04da30f

Browse files
committed
feat(update): keep developer-owned regions with {% ignore %} tag (#2184)
Replaced the literal comment-marker approach with a language-agnostic Jinja `{% ignore %}` / `{% endignore %}` tag. The block renders on `copier copy` but is omitted from the renders Copier produces internally during `copier update`, so its content stays developer-owned while no Copier syntax leaks into rendered files. Because the region is absent from both the old and new template renders that feed the 3-way merge, template changes inside it never reach the diff and the developer's own version survives, degrading to a normal Copier conflict only when the template edits lines directly adjacent to the block. - Added `IgnoreExtension`, compiling the tag to the equivalent of `{% if _copier_operation != 'update' %}...{% endif %}`. - Expose `_copier_operation` in the file render context and merge it with per-call extra context to avoid duplicate-keyword render errors. - Removed `copier/_preserve.py` and its `_apply_update` wiring. - Updated the `updating.md` docs and replaced the tests.
1 parent 9170be0 commit 04da30f

6 files changed

Lines changed: 293 additions & 591 deletions

File tree

copier/_jinja_ext.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,57 @@ def _yield_support(
122122
return res
123123

124124

125+
class IgnoreExtension(Extension):
126+
r"""Jinja2 extension for the `ignore` tag.
127+
128+
Wraps a block of *developer-owned* template content that should be rendered
129+
on initial generation (`copier copy`) but omitted from the renders Copier
130+
produces internally during `copier update`. Because the block is absent from
131+
both the old and new template renders that feed the 3-way merge, template
132+
changes inside it never reach the diff, so the developer's own version of the
133+
region in the generated project is left untouched on every update.
134+
135+
Unlike literal marker comments, nothing about this tag survives into the
136+
rendered file, so it is language-agnostic and never leaks Copier syntax into
137+
generated projects.
138+
139+
The tag compiles to the equivalent of::
140+
141+
{% if _copier_operation != 'update' %}...{% endif %}
142+
143+
so it relies solely on the `_copier_operation` render context variable and
144+
needs no runtime support hook.
145+
146+
!!! example
147+
148+
```pycon
149+
>>> from jinja2.sandbox import SandboxedEnvironment
150+
>>> from copier._jinja_ext import IgnoreExtension
151+
>>> env = SandboxedEnvironment(extensions=[IgnoreExtension])
152+
>>> template = env.from_string(
153+
... "keep\n{% ignore %}scaffold{% endignore %}\nkeep"
154+
... )
155+
>>> template.render({"_copier_operation": "copy"})
156+
'keep\nscaffold\nkeep'
157+
>>> template.render({"_copier_operation": "update"})
158+
'keep\n\nkeep'
159+
```
160+
"""
161+
162+
tags = {"ignore"}
163+
164+
def parse(self, parser: Parser) -> nodes.Node:
165+
"""Parse the `ignore` tag into a conditional on the current operation."""
166+
lineno = next(parser.stream).lineno
167+
body = parser.parse_statements(("name:endignore",), drop_needle=True)
168+
# Render the body for every operation except `update`; during an update
169+
# Copier omits it so the region stays developer-owned.
170+
test = nodes.Compare(
171+
nodes.Name("_copier_operation", "load", lineno=lineno),
172+
[nodes.Operand("ne", nodes.Const("update", lineno=lineno))],
173+
lineno=lineno,
174+
)
175+
return nodes.If(test, body, [], [], lineno=lineno)
176+
177+
125178
class UnsetError(UndefinedError): ...

copier/_main.py

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,7 @@
4545
from questionary import confirm, unsafe_prompt
4646

4747
from ._deprecation import deprecate_answers_file_template_path
48-
from ._jinja_ext import YieldExtension, get_yield_context
49-
from ._preserve import (
50-
capture_preserved_regions,
51-
find_marker_files,
52-
restore_preserved_regions,
53-
)
48+
from ._jinja_ext import IgnoreExtension, YieldExtension, get_yield_context
5449
from ._settings import Settings, SettingsModel, is_trusted_repository
5550
from ._subproject import Subproject
5651
from ._template import Task, Template
@@ -473,6 +468,7 @@ def _render_context(self) -> AnyByStrMutableMapping:
473468
_folder_name=self.subproject.local_abspath.name,
474469
_copier_python=sys.executable,
475470
_copier_phase=Phase.current(),
471+
_copier_operation=_operation.get(),
476472
)
477473

478474
def _path_matcher(self, patterns: Iterable[str]) -> Callable[[Path], bool]:
@@ -712,6 +708,7 @@ def jinja_env(self) -> SandboxedEnvironment:
712708
default_extensions = [
713709
"jinja2_ansible_filters.AnsibleCoreFiltersExtension",
714710
YieldExtension,
711+
IgnoreExtension,
715712
]
716713
extensions = default_extensions + list(self.template.jinja_extensions)
717714
envops = dict(self.template.envops)
@@ -857,7 +854,7 @@ def _render_file( # noqa: C901
857854
new_content = src_abspath.read_bytes()
858855
else:
859856
new_content = tpl.render(
860-
**self._render_context(), **(extra_context or {})
857+
{**self._render_context(), **(extra_context or {})}
861858
).encode()
862859
if get_yield_context(self.jinja_env).yield_name:
863860
raise YieldTagInFileError(
@@ -1180,7 +1177,7 @@ def _render_string(
11801177
Additional variables to use for rendering the template.
11811178
"""
11821179
tpl = self.jinja_env.from_string(string)
1183-
return tpl.render(**self._render_context(), **(extra_context or {}))
1180+
return tpl.render({**self._render_context(), **(extra_context or {})})
11841181

11851182
def _render_value(
11861183
self, value: _T, extra_context: AnyByStrDict | None = None
@@ -1400,20 +1397,6 @@ def _apply_update(self) -> None: # noqa: C901
14001397
ask=(),
14011398
) as old_worker:
14021399
old_worker.run_copy()
1403-
# Capture developer-owned regions marked for preservation.
1404-
# The old render tells us which template-managed files can contain
1405-
# markers, so we read the developer's current content only from
1406-
# those files (not the whole project tree). The captured content is
1407-
# then injected into every intermediate render so the merge sees
1408-
# identical region content on all sides and keeps it.
1409-
old_copy_root = old_copy / subproject_subdir
1410-
preserved = capture_preserved_regions(
1411-
self.subproject.local_abspath, find_marker_files(old_copy_root)
1412-
)
1413-
# Inject preserved regions into the old render (the merge base) so
1414-
# it matches the current project there - this keeps template changes
1415-
# inside those regions out of the computed diff.
1416-
restore_preserved_regions(old_copy_root, preserved)
14171400
# Run pre-migration tasks
14181401
with Phase.use(Phase.MIGRATE):
14191402
self._execute_tasks(
@@ -1467,10 +1450,6 @@ def _apply_update(self) -> None: # noqa: C901
14671450
current_worker.run_copy()
14681451
self.answers = current_worker.answers
14691452
self.answers.external = self._external_data()
1470-
# Inject preserved regions into the freshly rendered destination so
1471-
# the developer's content survives regardless of how the historical
1472-
# diff below applies.
1473-
restore_preserved_regions(self.subproject.local_abspath, preserved)
14741453
# Render with the same answers in an empty dir to avoid pollution
14751454
with replace(
14761455
self,
@@ -1491,10 +1470,6 @@ def _apply_update(self) -> None: # noqa: C901
14911470
ask=(),
14921471
) as new_worker:
14931472
new_worker.run_copy()
1494-
# Inject preserved regions into the new render (the merge "other"
1495-
# side) so it agrees with the base and current sides, leaving the
1496-
# region conflict-free during the merge.
1497-
restore_preserved_regions(new_copy / subproject_subdir, preserved)
14981473
with local.cwd(new_copy):
14991474
self._git_initialize_repo()
15001475
new_copy_head = git("rev-parse", "HEAD").strip()

copier/_preserve.py

Lines changed: 0 additions & 246 deletions
This file was deleted.

0 commit comments

Comments
 (0)