diff --git a/copier/main.py b/copier/main.py index 99916287e..c6ec79e50 100644 --- a/copier/main.py +++ b/copier/main.py @@ -954,6 +954,7 @@ def _apply_update(self) -> None: # noqa: C901 # Do a normal update in final destination with replace( self, + exclude=self.exclude + self.template.exclude_on_update, # Files can change due to the historical diff, and those # changes are not detected in this process, so it's better to # say nothing than lie. @@ -968,6 +969,7 @@ def _apply_update(self) -> None: # noqa: C901 dst_path=new_copy / subproject_subdir, data=self.answers.combined, # type: ignore[arg-type] defaults=True, + exclude=self.exclude + self.template.exclude_on_update, quiet=True, src_path=self.subproject.template.url, # type: ignore[union-attr] ) as new_worker: diff --git a/copier/template.py b/copier/template.py index feb71db54..f0d096a15 100644 --- a/copier/template.py +++ b/copier/template.py @@ -328,6 +328,14 @@ def exclude(self) -> tuple[str, ...]: DEFAULT_EXCLUDE if Path(self.subdirectory) == Path(".") else [], ) ) + + @cached_property + def exclude_on_update(self) -> tuple[str, ...]: + """Get update exclusions specified in the template. + + See [exclude_on_update][]. + """ + return tuple(self.config_data.get("exclude_on_update")) @cached_property def jinja_extensions(self) -> tuple[str, ...]: diff --git a/docs/configuring.md b/docs/configuring.md index 95577bcbf..ad6d9db44 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -893,6 +893,23 @@ The CLI option can be passed several times to add several patterns. copier copy --exclude '*' --exclude '!file-i-want' ./template ./destination ``` +### `exclude_on_update` + +- Format: `List[str]` +- CLI flags: N/A +- Default value: `[]` + +Excluded files applied only on update command. + +!!! example + + ```yaml title="copier.yml" + _exclude_on_update: + - "pre-commit.sample" + - "myfile.example" + - "*.dummy" + ``` + ### `force` - Format: `bool` diff --git a/tests/demo_exclude_on_update/copier.yml b/tests/demo_exclude_on_update/copier.yml new file mode 100644 index 000000000..9a8f0c0ab --- /dev/null +++ b/tests/demo_exclude_on_update/copier.yml @@ -0,0 +1,3 @@ +_exclude_on_update: + - "do_not_update.sample" + diff --git a/tests/demo_exclude_on_update/do_not_update.sample b/tests/demo_exclude_on_update/do_not_update.sample new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py index 9c8db3680..5a678f661 100644 --- a/tests/test_updatediff.py +++ b/tests/test_updatediff.py @@ -1290,3 +1290,64 @@ def test_update_with_new_file_in_template_and_project_via_migration( >>>>>>> after updating """ ) + + + +def test_exclude_on_update(tmp_path_factory: pytest.TempPathFactory) -> None: + # Template in v1 has a file with a single line; + # in v2 it changes that line. + # Meanwhile, downstream project appended contents to the first line. + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + filename = "README.md" + + # First, create the template with an initial file + build_file_tree( + { + (src / filename): "upstream version 1", + (src / "copier.yaml"): f"_exclude_on_update: ['{filename}']", + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{_copier_answers|to_nice_yaml}}" + ), + } + ) + with local.cwd(src): + git_init("hello template") + git("tag", "v1") + + # Generate the project a first time, assert the file exists + run_copy(str(src), dst, defaults=True, overwrite=True) + assert (dst / filename).exists() + assert "_commit: v1" in (dst / ".copier-answers.yml").read_text() + + # Start versioning the generated project + with local.cwd(dst): + git_init("hello project") + + # After first commit, change the file, commit again + Path(filename).write_text("upstream version 1 + downstream") + git("commit", "-am", "updated file") + + # Now change the template + with local.cwd(src): + # Update the file + Path(filename).write_text("upstream version 2") + + # Commit the changes + git("add", ".", "-A") + git("commit", "-m", "change line in file") + git("tag", "v2") + + # Finally, update the generated project + run_update(dst_path=dst, defaults=True, overwrite=True, conflict="inline") + assert "_commit: v2" in (dst / ".copier-answers.yml").read_text() + + # Assert that the file still exists, and was left untouched + assert (dst / filename).exists() + + expected_contents = dedent( + """\ + upstream version 1 + downstream + """ + ) + assert (dst / filename).read_text().splitlines() == expected_contents.splitlines() + assert not (dst / f"{filename}.rej").exists()