Skip to content

Commit 490da4a

Browse files
committed
feat(update): preserve developer-owned regions across updates (#2184)
Templates often ship files with dummy content or TODO markers that the developer replaces right after generation. Previously `copier update` treated those regions like any other template-owned content, so a change to the placeholder in the template could clobber the developer's work or raise a needless merge conflict. Template authors can now wrap a developer-owned region in literal marker comments that survive rendering: # copier:preserve:start <optional-id> ...developer-owned content... # copier:preserve:end <optional-id> During an update the content between the markers is captured from the current project and injected into every intermediate render (old render, new render, and the freshly rendered destination) so all three merge sides agree on the region. The merge therefore keeps the developer's content conflict-free, while the marker lines and surrounding content keep updating from the template as usual. - Added `copier/_preserve.py` with the marker parsing, capture and restore primitives. - Wired capture/restore into `Worker._apply_update`, scoping the scan to template-managed files discovered in the old render. - Documented the feature in `docs/updating.md`. - Added unit and integration tests in `tests/test_preserve.py`.
1 parent 02e1574 commit 490da4a

4 files changed

Lines changed: 608 additions & 0 deletions

File tree

copier/_main.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646

4747
from ._deprecation import deprecate_answers_file_template_path
4848
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+
)
4954
from ._settings import Settings, SettingsModel, is_trusted_repository
5055
from ._subproject import Subproject
5156
from ._template import Task, Template
@@ -1395,6 +1400,20 @@ def _apply_update(self) -> None: # noqa: C901
13951400
ask=(),
13961401
) as old_worker:
13971402
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)
13981417
# Run pre-migration tasks
13991418
with Phase.use(Phase.MIGRATE):
14001419
self._execute_tasks(
@@ -1448,6 +1467,10 @@ def _apply_update(self) -> None: # noqa: C901
14481467
current_worker.run_copy()
14491468
self.answers = current_worker.answers
14501469
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)
14511474
# Render with the same answers in an empty dir to avoid pollution
14521475
with replace(
14531476
self,
@@ -1468,6 +1491,10 @@ def _apply_update(self) -> None: # noqa: C901
14681491
ask=(),
14691492
) as new_worker:
14701493
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)
14711498
with local.cwd(new_copy):
14721499
self._git_initialize_repo()
14731500
new_copy_head = git("rev-parse", "HEAD").strip()

copier/_preserve.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
"""Preserve user-owned regions across ``copier update``.
2+
3+
Templates often ship files with dummy content or ``TODO`` markers that the
4+
developer is expected to replace right after generating a project. On a later
5+
``copier update``, Copier's 3-way merge treats those regions like any other
6+
template-owned content, so a change to the placeholder in the template can
7+
clobber the developer's implementation (or raise a needless merge conflict).
8+
9+
This module lets a template author wrap such a region in a pair of *preserve
10+
markers*. Everything **between** the markers becomes developer-owned: its
11+
content is captured from the existing project before an update and written
12+
back into every intermediate render, so the merge sees identical content on
13+
all sides and leaves the developer's version untouched. The marker lines
14+
themselves, and everything around them, keep updating from the template as
15+
usual.
16+
17+
A marker is any line that *contains* the sentinel token, so it works
18+
regardless of the host language's comment syntax::
19+
20+
# copier:preserve:start greeting
21+
def greeting() -> str:
22+
return "Hello from the developer!"
23+
# copier:preserve:end greeting
24+
25+
The optional identifier after the token (``greeting`` above) lets Copier match
26+
a region to its counterpart even if the region moves within the file between
27+
template versions. Unidentified regions are matched by their order of
28+
appearance instead.
29+
30+
See the ``updating.md`` documentation for the user-facing description.
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import re
36+
from collections.abc import Iterable
37+
from dataclasses import dataclass
38+
from pathlib import Path
39+
40+
__all__ = [
41+
"PRESERVE_END_TOKEN",
42+
"PRESERVE_START_TOKEN",
43+
"capture_preserved_regions",
44+
"find_marker_files",
45+
"restore_preserved_regions",
46+
]
47+
48+
#: Sentinel token opening a preserved region.
49+
PRESERVE_START_TOKEN = "copier:preserve:start"
50+
#: Sentinel token closing a preserved region.
51+
PRESERVE_END_TOKEN = "copier:preserve:end"
52+
53+
# The identifier (if any) follows the token after a ``:`` or whitespace and
54+
# runs until the next whitespace, so it can be embedded in a comment.
55+
_START_RE = re.compile(re.escape(PRESERVE_START_TOKEN) + r"(?:[:\s]+(?P<id>\S+))?")
56+
_END_RE = re.compile(re.escape(PRESERVE_END_TOKEN) + r"(?:[:\s]+(?P<id>\S+))?")
57+
58+
59+
class PreserveMarkerError(ValueError):
60+
"""Raised when preserve markers in a file are unbalanced or nested."""
61+
62+
63+
@dataclass(frozen=True)
64+
class _Region:
65+
"""A single preserved region parsed from a file."""
66+
67+
identifier: str | None
68+
body: str
69+
start_line: int # index of the start-marker line
70+
end_line: int # index of the end-marker line
71+
72+
73+
# A hashable key that maps a region to its counterpart in another render.
74+
_RegionKey = str | tuple[str, int]
75+
76+
77+
def _parse_regions(text: str) -> list[_Region]:
78+
"""Parse all preserved regions from ``text``.
79+
80+
Raises:
81+
PreserveMarkerError: If a start marker is nested inside another, or a
82+
start/end marker has no matching counterpart.
83+
"""
84+
lines = text.splitlines(keepends=True)
85+
regions: list[_Region] = []
86+
open_identifier: str | None = None
87+
open_start: int | None = None
88+
open_body: list[str] = []
89+
for idx, line in enumerate(lines):
90+
if (match := _START_RE.search(line)) is not None:
91+
if open_start is not None:
92+
raise PreserveMarkerError(
93+
f"nested preserve start marker at line {idx + 1}"
94+
)
95+
open_identifier = match.group("id")
96+
open_start = idx
97+
open_body = []
98+
elif _END_RE.search(line) is not None:
99+
if open_start is None:
100+
raise PreserveMarkerError(
101+
f"preserve end marker without matching start at line {idx + 1}"
102+
)
103+
regions.append(
104+
_Region(
105+
identifier=open_identifier,
106+
body="".join(open_body),
107+
start_line=open_start,
108+
end_line=idx,
109+
)
110+
)
111+
open_start = None
112+
open_identifier = None
113+
open_body = []
114+
elif open_start is not None:
115+
open_body.append(line)
116+
if open_start is not None:
117+
raise PreserveMarkerError(
118+
f"preserve start marker at line {open_start + 1} is never closed"
119+
)
120+
return regions
121+
122+
123+
def _keyed_regions(regions: list[_Region]) -> list[tuple[_RegionKey, _Region]]:
124+
"""Pair each region with a key used to match it across renders.
125+
126+
Named regions are keyed by their identifier; unnamed regions are keyed by
127+
their positional index among the unnamed regions.
128+
"""
129+
keyed: list[tuple[_RegionKey, _Region]] = []
130+
anonymous_index = 0
131+
for region in regions:
132+
if region.identifier is not None:
133+
keyed.append((region.identifier, region))
134+
else:
135+
keyed.append((("", anonymous_index), region))
136+
anonymous_index += 1
137+
return keyed
138+
139+
140+
def _read_text(path: Path) -> str | None:
141+
"""Read ``path`` as UTF-8 text, or return ``None`` if that's not possible."""
142+
if not path.is_file() or path.is_symlink():
143+
return None
144+
try:
145+
return path.read_text(encoding="utf-8")
146+
except (OSError, UnicodeDecodeError):
147+
return None
148+
149+
150+
def find_marker_files(root: Path) -> set[Path]:
151+
"""Return the paths (relative to ``root``) of files with preserve markers.
152+
153+
Only regular, UTF-8-decodable files that contain the start token are
154+
returned; the ``.git`` directory is skipped. This is meant to be run
155+
against a *template render*, which bounds the scan to template-managed
156+
files instead of the whole (potentially huge) project tree.
157+
"""
158+
found: set[Path] = set()
159+
for path in root.rglob("*"):
160+
if ".git" in path.parts:
161+
continue
162+
text = _read_text(path)
163+
if text is not None and PRESERVE_START_TOKEN in text:
164+
found.add(path.relative_to(root))
165+
return found
166+
167+
168+
def capture_preserved_regions(
169+
root: Path, relpaths: Iterable[Path] | None = None
170+
) -> dict[Path, dict[_RegionKey, str]]:
171+
"""Capture the content of preserved regions under ``root``.
172+
173+
Args:
174+
root: Directory holding the files to read (typically the existing
175+
project).
176+
relpaths: The candidate files to inspect, relative to ``root``. When
177+
``None``, ``root`` is scanned recursively; callers that already
178+
know the template-managed files (see :func:`find_marker_files`)
179+
should pass them to avoid walking unrelated project content.
180+
181+
Returns:
182+
A mapping from each file's path (relative to ``root``) to a mapping of
183+
region key to the region's current body. Only files that contain at
184+
least one well-formed preserved region are included. Files with
185+
malformed markers are skipped so they never break an update.
186+
"""
187+
if relpaths is None:
188+
relpaths = find_marker_files(root)
189+
captured: dict[Path, dict[_RegionKey, str]] = {}
190+
for relpath in relpaths:
191+
text = _read_text(root / relpath)
192+
if text is None or PRESERVE_START_TOKEN not in text:
193+
continue
194+
try:
195+
regions = _parse_regions(text)
196+
except PreserveMarkerError:
197+
continue
198+
if regions:
199+
captured[relpath] = {
200+
key: region.body for key, region in _keyed_regions(regions)
201+
}
202+
return captured
203+
204+
205+
def _apply_regions(text: str, bodies: dict[_RegionKey, str]) -> str:
206+
"""Return ``text`` with each matching region body replaced from ``bodies``."""
207+
lines = text.splitlines(keepends=True)
208+
result: list[str] = []
209+
cursor = 0
210+
for key, region in _keyed_regions(_parse_regions(text)):
211+
if key not in bodies:
212+
continue
213+
# Emit everything up to and including the start-marker line, then the
214+
# captured body, and resume from the end-marker line so the (possibly
215+
# updated) marker lines themselves are preserved from the template.
216+
result.extend(lines[cursor : region.start_line + 1])
217+
result.append(bodies[key])
218+
cursor = region.end_line
219+
result.extend(lines[cursor:])
220+
return "".join(result)
221+
222+
223+
def restore_preserved_regions(
224+
root: Path, captured: dict[Path, dict[_RegionKey, str]]
225+
) -> None:
226+
"""Write captured region bodies back into the matching files under ``root``.
227+
228+
Files, or individual regions, that are missing from a given render are left
229+
as-is, so brand-new regions introduced by the template still appear. Files
230+
with malformed markers are skipped.
231+
232+
Args:
233+
root: Directory whose files should receive the captured content.
234+
captured: The mapping returned by :func:`capture_preserved_regions`.
235+
"""
236+
for relpath, bodies in captured.items():
237+
target = root / relpath
238+
text = _read_text(target)
239+
if text is None or PRESERVE_START_TOKEN not in text:
240+
continue
241+
try:
242+
new_text = _apply_regions(text, bodies)
243+
except PreserveMarkerError:
244+
continue
245+
if new_text != text:
246+
target.write_text(new_text, encoding="utf-8")

docs/updating.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,50 @@ git clean -d -i # remove untracked files and folders
255255
If you want fine-grained control to restore files selectively, read the output of the
256256
`git status` command attentively. It shows all the commands you may need as hints!
257257

258+
## Preserving developer-owned regions
259+
260+
Templates often ship files with dummy content or `TODO` markers that you are meant to
261+
replace right after generating a project (e.g. a stub function body, a placeholder
262+
config value). By default, if the template later changes that placeholder, the
263+
[update](#how-the-update-works) 3-way merge treats it like any other template-owned
264+
content, so the template's new placeholder can overwrite your implementation or raise a
265+
needless merge conflict.
266+
267+
To mark a region as **developer-owned**, wrap it between a pair of _preserve markers_.
268+
Everything _between_ the markers is kept from your project on every update, while the
269+
marker lines themselves and the surrounding content keep updating from the template as
270+
usual.
271+
272+
A marker is any line that _contains_ the sentinel token, so it works in any language by
273+
placing it inside a comment:
274+
275+
```python title="app.py.jinja"
276+
HEADER = "generated by {{ project_name }}"
277+
278+
# copier:preserve:start greeting
279+
def greeting() -> str:
280+
# TODO: implement your greeting
281+
return "dummy greeting"
282+
# copier:preserve:end greeting
283+
284+
FOOTER = "generated by {{ project_name }}"
285+
```
286+
287+
After generating the project you replace the region's body with your real
288+
implementation. On the next `copier update`, your body is kept even if the template
289+
changed the placeholder, while `HEADER` and `FOOTER` still receive template updates.
290+
291+
!!! important
292+
293+
The markers must appear in the **rendered** file, so use plain comment lines
294+
containing the token — _not_ Jinja comments (`{# … #}`), which are stripped during
295+
rendering and would leave nothing for Copier to find on update.
296+
297+
The optional identifier after the token (`greeting` above) lets Copier match a region to
298+
its counterpart even if the region moves within the file between template versions. When
299+
omitted, regions are matched by their order of appearance in the file. Nested markers are
300+
not supported, and a file with unbalanced markers is left untouched.
301+
258302
## Checking for updates
259303

260304
Copier provides a subcommand `copier check-update` that can be used to check if there

0 commit comments

Comments
 (0)