Skip to content

Commit 9947f3c

Browse files
committed
feat(updating)!: apply pre-update migrations on fresh project from current template version
BREAKING CHANGE: Pre-update migrations are now run on the fresh project generated from the current template version. In most cases, this should only improve update quality by reducing merge conflicts, but it is possible that this change will break template updates in rare cases. Thus, this change is marked as breaking out of an abundance of caution.
1 parent 198ab4a commit 9947f3c

3 files changed

Lines changed: 97 additions & 7 deletions

File tree

copier/_main.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -368,11 +368,15 @@ def _answers_to_remember(self) -> Mapping[str, Any]:
368368
)
369369
return answers
370370

371-
def _execute_tasks(self, tasks: Sequence[Task]) -> None:
371+
def _execute_tasks(
372+
self, tasks: Sequence[Task], *, directory: Path | None = None
373+
) -> None:
372374
"""Run the given tasks.
373375
374376
Arguments:
375377
tasks: The list of tasks to run.
378+
directory: The working directory to run the tasks in. Defaults to the
379+
subproject's path.
376380
"""
377381
operation = _operation.get()
378382
for i, task in enumerate(tasks):
@@ -404,7 +408,7 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None:
404408
working_directory = (
405409
# We can't use _render_path here, as that function has special handling
406410
# for files in the template
407-
self.subproject.local_abspath
411+
(directory or self.subproject.local_abspath)
408412
/ Path(self._render_string(str(task.working_directory), extra_context))
409413
).absolute()
410414

@@ -1396,11 +1400,13 @@ def _apply_update(self) -> None: # noqa: C901
13961400
) as old_worker:
13971401
old_worker.run_copy()
13981402

1399-
# Run pre-migration tasks.
14001403
with Phase.use(Phase.MIGRATE):
1401-
self._execute_tasks(
1402-
self.template.migration_tasks("before", self.subproject.template) # type: ignore[arg-type]
1404+
pre_migration_tasks = self.template.migration_tasks(
1405+
"before",
1406+
self.subproject.template, # type: ignore[arg-type]
14031407
)
1408+
# Run pre-migration tasks on the current project.
1409+
self._execute_tasks(pre_migration_tasks)
14041410

14051411
# Clear last answers cache to load possible answers migration if the
14061412
# `skip_answered` flag is not set.
@@ -1474,6 +1480,10 @@ def _apply_update(self) -> None: # noqa: C901
14741480

14751481
# Initialize a Git repository.
14761482
git("init")
1483+
# Run pre-migration tasks on old copy.
1484+
self._execute_tasks(
1485+
pre_migration_tasks, directory=old_copy / subproject_subdir
1486+
)
14771487
# Stage all files including Git-ignored ones.
14781488
git("add", "-f", ".")
14791489
# Make a commit to run Git hooks if applicable.

docs/updating.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ project_updated("updated project")
133133
project_full("fully updated<br>and migrated project")
134134
135135
update["3-way merge"]
136-
regen_current["generate and run tasks"]
136+
regen_current["generate and run tasks<br>& apply pre-migrations"]
137137
regen_latest["generate and run tasks"]
138138
139139
%% edges ----------------------------------------------------------
@@ -161,7 +161,8 @@ class regen_current,regen_latest,update blackborder;
161161
As you can see here, `copier` does several things:
162162

163163
- Regenerate the project fresh from the **current** template version, using the
164-
project's existing answers – this becomes the merge-base.
164+
project's existing answers (with pre-migrations applied afterwards) – this becomes
165+
the merge-base.
165166
- Regenerate the project fresh from the **latest** template version, using the same
166167
answers (with pre-migrations applied to the project beforehand).
167168
- Build a synthetic Git commit graph from these three states: the current-version

tests/test_migrations.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Any
55

66
import pytest
7+
from inline_snapshot import snapshot
78
from plumbum import local
89

910
from copier import run_copy, run_update
@@ -513,6 +514,84 @@ def test_migration_env_variables(
513514
assert (f"{variable}={value}" in env) == with_version
514515

515516

517+
def test_pre_migration_runs_on_old_copy(
518+
tmp_path_factory: pytest.TempPathFactory,
519+
) -> None:
520+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
521+
522+
build_file_tree(
523+
{
524+
src / "copier.yml": "_subdirectory: template/",
525+
src / "template" / "{{ _copier_conf.answers_file }}.jinja": (
526+
"{{ _copier_answers|to_yaml }}"
527+
),
528+
src / "template" / "pyproject.toml.jinja": (
529+
"""\
530+
[tool.poetry.group.dev.dependencies]
531+
pytest = "*"
532+
"""
533+
),
534+
}
535+
)
536+
git_save(src, tag="v1")
537+
538+
build_file_tree(
539+
{
540+
src / "copier.yml": (
541+
"""\
542+
_subdirectory: template/
543+
544+
_migrations:
545+
- version: v2
546+
when: "{{ _stage == 'before' }}"
547+
command: "{{ _copier_python }} {{ _copier_conf.src_path / 'migrate_pep735.py' }}"
548+
"""
549+
),
550+
src / "template" / "pyproject.toml.jinja": (
551+
"""\
552+
[dependency-groups]
553+
dev = ["pytest"]
554+
"""
555+
),
556+
src / "migrate_pep735.py": (
557+
"""\
558+
from pathlib import Path
559+
import tomlkit
560+
561+
pyproject = Path("pyproject.toml")
562+
doc = tomlkit.parse(pyproject.read_bytes())
563+
print(pyproject.read_bytes())
564+
565+
deps = sorted(doc["tool"]["poetry"]["group"]["dev"]["dependencies"])
566+
del doc["tool"]["poetry"]["group"]["dev"]
567+
568+
dep_groups = tomlkit.table()
569+
dep_groups.add("dev", deps)
570+
doc.add("dependency-groups", dep_groups)
571+
572+
pyproject.write_text(tomlkit.dumps(doc).lstrip())
573+
"""
574+
),
575+
}
576+
)
577+
git_save(src, tag="v2")
578+
579+
run_copy(str(src), dst, vcs_ref="v1")
580+
581+
git_save(dst, "init")
582+
pyproject = dst / "pyproject.toml"
583+
pyproject.write_text(pyproject.read_text() + 'mypy = "*"\n')
584+
git_save(dst, "add mypy in poetry group")
585+
586+
run_update(dst, overwrite=True, unsafe=True)
587+
assert pyproject.read_text() == snapshot(
588+
"""\
589+
[dependency-groups]
590+
dev = ["mypy", "pytest"]
591+
"""
592+
)
593+
594+
516595
@pytest.mark.parametrize("with_version", [True, False])
517596
def test_migration_jinja_variables(
518597
tmp_path_factory: pytest.TempPathFactory, with_version: bool

0 commit comments

Comments
 (0)