copier/tests/test_updatediff.py
Jairo Llopis 67cc4ffde3
feat: nix support
- Provide a dev shell.
- Provide a nix package.
- Provide a nix flake.
- Development environment based on direnv.
- Docs.
- Configure Gitpod to use direnv and nix.
- Configure Cachix out of the box, and document how to use it.
- Add direnv and nix to CI.
- Satisfy some linters that came from Precommix, even when Precommix was later discarded.
- Mark some tests as impure.
- Run only pure tests when building Copier with Nix.
- Add poetry loader to direnv.
- Update contribution guide.
2023-01-18 09:40:08 +00:00

611 lines
21 KiB
Python

import platform
from pathlib import Path
from shutil import rmtree
from textwrap import dedent
from typing import Optional
import pytest
import yaml
from plumbum import local
from plumbum.cmd import git
from copier import Worker, copy
from copier.cli import CopierApp
from copier.main import run_copy, run_update
from copier.types import RelativePath
from .helpers import BRACKET_ENVOPS_JSON, SUFFIX_TMPL, build_file_tree
@pytest.mark.impure
def test_updatediff(tmp_path_factory):
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
# Prepare repo bundle
repo = src / "repo"
bundle = src / "demo_updatediff_repo.bundle"
last_commit = ""
build_file_tree(
{
repo
/ ".copier-answers.yml.jinja": """\
# Changes here will be overwritten by Copier
{{ _copier_answers|to_nice_yaml }}
""",
repo
/ "copier.yml": """\
_envops:
"keep_trailing_newline": True
project_name: to become a pirate
author_name: Guybrush
""",
repo
/ "README.txt.jinja": """
Let me introduce myself.
My name is {{author_name}}, and my project is {{project_name}}.
Thanks for your attention.
""",
}
)
with local.cwd(repo):
git("init")
git("add", ".")
git("commit", "-m", "Guybrush wants to be a pirate")
git("tag", "v0.0.1")
build_file_tree(
{
repo
/ "copier.yml": """\
_envops:
"keep_trailing_newline": True
project_name: to become a pirate
author_name: Guybrush
_migrations:
- version: v0.0.1
before:
- touch before-v0.0.1
after:
- touch after-v0.0.1
- version: v0.0.2
before:
- touch before-v0.0.2
after:
- touch after-v0.0.2
- version: v1.0.0
before:
- touch before-v1.0.0
after:
- touch after-v1.0.0
""",
}
)
with local.cwd(repo):
git("init")
git("add", ".")
git("commit", "-m", "Add migrations")
git("tag", "v0.0.2")
build_file_tree(
{
repo
/ "copier.yml": """\
_envops:
"keep_trailing_newline": True
project_name: to rule
author_name: Elaine
_migrations:
- version: v0.0.1
before:
- touch before-v0.0.1
after:
- touch after-v0.0.1
- version: v0.0.2
before:
- touch before-v0.0.2
after:
- touch after-v0.0.2
- version: v1.0.0
before:
- touch before-v1.0.0
after:
- touch after-v1.0.0
""",
repo
/ "README.txt.jinja": """
Let me introduce myself.
My name is {{author_name}}.
My project is {{project_name}}.
Thanks for your attention.
""",
}
)
with local.cwd(repo):
git("init")
git("add", ".")
git("commit", "-m", "Elaine wants to rule")
git("bundle", "create", bundle, "--all")
last_commit = git("describe", "--tags").strip()
# Generate repo bundle
target = dst / "target"
readme = target / "README.txt"
answers = target / ".copier-answers.yml"
commit = git["commit", "--all"]
# Run copier 1st time, with specific tag
CopierApp.invoke(
"copy",
str(bundle),
str(target),
defaults=True,
overwrite=True,
vcs_ref="v0.0.1",
)
# Check it's copied OK
assert answers.read_text() == dedent(
f"""\
# Changes here will be overwritten by Copier
_commit: v0.0.1
_src_path: {bundle}
author_name: Guybrush
project_name: to become a pirate\n
"""
)
assert readme.read_text() == dedent(
"""
Let me introduce myself.
My name is Guybrush, and my project is to become a pirate.
Thanks for your attention.
"""
)
# Init destination as a new independent git repo
with local.cwd(target):
git("init")
# Configure git in case you're running in CI
git("config", "user.name", "Copier Test")
git("config", "user.email", "test@copier")
# Commit changes
git("add", ".")
commit("-m", "hello world")
# Emulate the user modifying the README by hand
with open(readme, "w") as readme_fd:
readme_fd.write(
dedent(
"""
Let me introduce myself.
My name is Guybrush, and my project is to become a pirate.
Thanks for your grog.
"""
)
)
commit("-m", "I prefer grog")
# Update target to latest tag and check it's updated in answers file
CopierApp.invoke(defaults=True, overwrite=True)
assert answers.read_text() == dedent(
f"""\
# Changes here will be overwritten by Copier
_commit: v0.0.2
_src_path: {bundle}
author_name: Guybrush
project_name: to become a pirate\n
"""
)
# Check migrations were executed properly
assert not (target / "before-v0.0.1").is_file()
assert not (target / "after-v0.0.1").is_file()
assert (target / "before-v0.0.2").is_file()
assert (target / "after-v0.0.2").is_file()
(target / "before-v0.0.2").unlink()
(target / "after-v0.0.2").unlink()
assert not (target / "before-v1.0.0").is_file()
assert not (target / "after-v1.0.0").is_file()
commit("-m", "Update template to v0.0.2")
# Update target to latest commit, which is still untagged
CopierApp.invoke(defaults=True, overwrite=True, vcs_ref="HEAD")
# Check no new migrations were executed
assert not (target / "before-v0.0.1").is_file()
assert not (target / "after-v0.0.1").is_file()
assert not (target / "before-v0.0.2").is_file()
assert not (target / "after-v0.0.2").is_file()
assert not (target / "before-v1.0.0").is_file()
assert not (target / "after-v1.0.0").is_file()
# Check it's updated OK
assert answers.read_text() == dedent(
f"""\
# Changes here will be overwritten by Copier
_commit: {last_commit}
_src_path: {bundle}
author_name: Guybrush
project_name: to become a pirate\n
"""
)
assert readme.read_text() == dedent(
"""
Let me introduce myself.
My name is Guybrush.
My project is to become a pirate.
Thanks for your grog.
"""
)
commit("-m", f"Update template to {last_commit}")
assert not git("status", "--porcelain")
# No more updates exist, so updating again should change nothing
CopierApp.invoke(defaults=True, overwrite=True, vcs_ref="HEAD")
assert not git("status", "--porcelain")
# If I change an option, it updates properly
copy(
data={"author_name": "Largo LaGrande", "project_name": "to steal a lot"},
defaults=True,
overwrite=True,
vcs_ref="HEAD",
)
assert readme.read_text() == dedent(
"""
Let me introduce myself.
My name is Largo LaGrande.
My project is to steal a lot.
Thanks for your grog.
"""
)
commit("-m", "Subproject evolved")
# Reapply template ignoring subproject evolution
Worker(
data={"author_name": "Largo LaGrande", "project_name": "to steal a lot"},
defaults=True,
overwrite=True,
vcs_ref="HEAD",
).run_copy()
assert readme.read_text() == dedent(
"""
Let me introduce myself.
My name is Largo LaGrande.
My project is to steal a lot.
Thanks for your attention.
"""
)
# This fails on Windows because there's some problem while detecting
# the diff. It seems like an older Git version were being used, while
# that's not the case...
# FIXME Some generous Windows power user please fix this test!
@pytest.mark.xfail(
condition=platform.system() == "Windows", reason="Git broken on Windows?"
)
@pytest.mark.impure
def test_commit_hooks_respected(tmp_path_factory):
"""Commit hooks are taken into account when producing the update diff."""
# Prepare source template v1
src, dst1, dst2 = map(tmp_path_factory.mktemp, ("src", "dst1", "dst2"))
with local.cwd(src):
build_file_tree(
{
"copier.yml": f"""
_envops: {BRACKET_ENVOPS_JSON}
_templates_suffix: {SUFFIX_TMPL}
_tasks:
- git init
- pre-commit install
- pre-commit run -a || true
what: grog
""",
"[[ _copier_conf.answers_file ]].tmpl": """
[[ _copier_answers|to_nice_yaml ]]
""",
".pre-commit-config.yaml": r"""
repos:
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.0.4
hooks:
- id: prettier
- repo: local
hooks:
- id: forbidden-files
name: forbidden files
entry: found forbidden files; remove them
language: fail
files: "\\.rej$"
""",
"life.yml.tmpl": """
# Following code should be reformatted by pre-commit after copying
Line 1: hello
Line 2: [[ what ]]
Line 3: bye
""",
}
)
git("init")
git("add", ".")
git("commit", "-m", "commit 1")
git("tag", "v1")
# Copy source template
copy(src_path=str(src), dst_path=dst1, defaults=True, overwrite=True)
with local.cwd(dst1):
life = Path("life.yml")
git("add", ".")
# 1st commit fails because pre-commit reformats life.yml
git("commit", "-am", "failed commit", retcode=1)
# 2nd commit works because it's already formatted
git("commit", "-am", "copied v1")
assert life.read_text() == dedent(
"""\
# Following code should be reformatted by pre-commit after copying
Line 1: hello
Line 2: grog
Line 3: bye
"""
)
# Evolve source template to v2
with local.cwd(src):
build_file_tree(
{
"life.yml.tmpl": """
# Following code should be reformatted by pre-commit after copying
Line 1: hello world
Line 2: grow up
Line 3: [[ what ]]
Line 4: grow old
Line 5: bye bye world
""",
}
)
git("init")
git("add", ".")
git("commit", "-m", "commit 2")
git("tag", "v2")
# Update subproject to v2
copy(dst_path=dst1, defaults=True, overwrite=True, conflict="rej")
with local.cwd(dst1):
git("commit", "-am", "copied v2")
assert life.read_text() == dedent(
"""\
# Following code should be reformatted by pre-commit after copying
Line 1: hello world
Line 2: grow up
Line 3: grog
Line 4: grow old
Line 5: bye bye world
"""
)
# No .rej files created (update diff was smart)
assert not git("status", "--porcelain")
# Subproject evolves
life.write_text(
dedent(
"""\
Line 1: hello world
Line 2: grow up
Line 2.5: make friends
Line 3: grog
Line 4: grow old
Line 4.5: no more work
Line 5: bye bye world
"""
)
)
git("commit", "-am", "subproject is evolved")
# A new subproject appears, which is a shallow clone of the 1st one.
# Using file:// prefix to allow local shallow clones.
git("clone", "--depth=1", f"file://{dst1}", dst2)
with local.cwd(dst2):
# Subproject re-updates just to change some values
copy(data={"what": "study"}, defaults=True, overwrite=True, conflict="rej")
git("commit", "-am", "re-updated to change values after evolving")
# Subproject evolution was respected up to sane possibilities.
# In an ideal world, this file would be exactly the same as what's written
# a few lines above, just changing "grog" for "study". However, that's nearly
# impossible to achieve, because each change hunk needs at least 1 line of
# context to let git apply that patch smartly, and that context couldn't be
# found because we changed data when updating, so the sanest thing we can
# do is to provide a .rej file to notify those
# unresolvable diffs. OTOH, some other changes are be applied.
# If some day you are able to produce that ideal result, you should be
# happy to modify these asserts.
assert life.read_text() == dedent(
"""\
Line 1: hello world
Line 2: grow up
Line 3: study
Line 4: grow old
Line 4.5: no more work
Line 5: bye bye world
"""
)
# This time a .rej file is unavoidable
assert Path(f"{life}.rej").is_file()
def test_update_from_tagged_to_head(src_repo, tmp_path):
# Build a template
with local.cwd(src_repo):
build_file_tree(
{
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}",
"example": "1",
}
)
git("add", "-A")
git("commit", "-m1")
# Publish v1 release
git("tag", "v1")
# New commit, no release
build_file_tree({"example": "2"})
git("commit", "-am2")
sha = git("rev-parse", "--short", "HEAD").strip()
# Copy it without specifying version
run_copy(src_path=str(src_repo), dst_path=tmp_path)
example = tmp_path / "example"
answers_file = tmp_path / ".copier-answers.yml"
assert example.read_text() == "1"
assert yaml.safe_load(answers_file.read_text())["_commit"] == "v1"
# Build repo on copy
with local.cwd(tmp_path):
git("init")
git("add", "-A")
git("commit", "-m3")
# Update project, it must let us do it
run_update(tmp_path, vcs_ref="HEAD", defaults=True, overwrite=True)
assert example.read_text() == "2"
assert yaml.safe_load(answers_file.read_text())["_commit"] == f"v1-1-g{sha}"
def test_skip_update(tmp_path_factory):
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
with local.cwd(src):
build_file_tree(
{
"copier.yaml": "_skip_if_exists: [skip_me]",
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
"skip_me": "1",
}
)
git("init")
git("add", ".")
git("commit", "-m1")
git("tag", "1.0.0")
run_copy(str(src), dst, defaults=True, overwrite=True)
skip_me = dst / "skip_me"
answers_file = dst / ".copier-answers.yml"
answers = yaml.safe_load(answers_file.read_text())
assert skip_me.read_text() == "1"
assert answers["_commit"] == "1.0.0"
skip_me.write_text("2")
with local.cwd(dst):
git("init")
git("add", ".")
git("commit", "-m1")
with local.cwd(src):
build_file_tree({"skip_me": "3"})
git("commit", "-am2")
git("tag", "2.0.0")
run_update(dst, defaults=True, overwrite=True)
answers = yaml.safe_load(answers_file.read_text())
assert skip_me.read_text() == "2"
assert answers["_commit"] == "2.0.0"
assert not (dst / "skip_me.rej").exists()
@pytest.mark.timeout(20)
@pytest.mark.parametrize(
"answers_file", (None, ".copier-answers.yml", ".custom.copier-answers.yaml")
)
def test_overwrite_answers_file_always(
tmp_path_factory, answers_file: Optional[RelativePath]
):
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
with local.cwd(src):
build_file_tree(
{
"copier.yaml": "question_1: true",
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
"answer_1.jinja": "{{ question_1 }}",
}
)
git("init")
git("add", ".")
git("commit", "-m1")
git("tag", "1")
build_file_tree({"copier.yaml": "question_1: false"})
git("commit", "-am2")
git("tag", "2")
# When copying, there's nothing to overwrite, overwrite=False shouldn't hang
run_copy(str(src), str(dst), vcs_ref="1", defaults=True, answers_file=answers_file)
with local.cwd(dst):
git("init")
git("add", ".")
git("commit", "-m1")
# When updating, the only thing to overwrite is the copier answers file,
# which shouldn't ask, so also this shouldn't hang with overwrite=False
run_update(defaults=True, answers_file=answers_file)
answers = yaml.safe_load(
Path(dst, answers_file or ".copier-answers.yml").read_bytes()
)
assert answers["question_1"] is True
assert answers["_commit"] == "2"
assert (dst / "answer_1").read_text() == "True"
def test_file_removed(src_repo, tmp_path):
# Add a file in the template repo
with local.cwd(src_repo):
build_file_tree(
{
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
"1.txt": "content 1",
Path("dir 2", "2.txt"): "content 2",
Path("dir 3", "subdir 3", "3.txt"): "content 3",
Path("dir 4", "subdir 4", "4.txt"): "content 4",
Path("dir 5", "subdir 5", "5.txt"): "content 5",
}
)
git("add", "-A")
git("commit", "-m1")
git("tag", "1")
# Copy in subproject
with local.cwd(tmp_path):
git("init")
run_copy(str(src_repo))
# Subproject has an extra file
build_file_tree(
{
"I.txt": "content I",
Path("dir II", "II.txt"): "content II",
Path("dir 3", "subdir III", "III.txt"): "content III",
Path("dir 4", "subdir 4", "IV.txt"): "content IV",
}
)
git("add", "-A")
git("commit", "-m2")
# All files exist
assert tmp_path.joinpath(".copier-answers.yml").is_file()
assert tmp_path.joinpath("1.txt").is_file()
assert tmp_path.joinpath("dir 2", "2.txt").is_file()
assert tmp_path.joinpath("dir 3", "subdir 3", "3.txt").is_file()
assert tmp_path.joinpath("dir 4", "subdir 4", "4.txt").is_file()
assert tmp_path.joinpath("dir 5", "subdir 5", "5.txt").is_file()
assert tmp_path.joinpath("I.txt").is_file()
assert tmp_path.joinpath("dir II", "II.txt").is_file()
assert tmp_path.joinpath("dir 3", "subdir III", "III.txt").is_file()
assert tmp_path.joinpath("dir 4", "subdir 4", "IV.txt").is_file()
# Template removes file 1
with local.cwd(src_repo):
Path("1.txt").unlink()
rmtree("dir 2")
rmtree("dir 3")
rmtree("dir 4")
rmtree("dir 5")
build_file_tree({"6.txt": "content 6"})
git("add", "-A")
git("commit", "-m3")
git("tag", "2")
# Subproject updates
with local.cwd(tmp_path):
run_update(conflict="rej")
# Check what must still exist
assert tmp_path.joinpath(".copier-answers.yml").is_file()
assert tmp_path.joinpath("I.txt").is_file()
assert tmp_path.joinpath("dir II", "II.txt").is_file()
assert tmp_path.joinpath("dir 3", "subdir III", "III.txt").is_file()
assert tmp_path.joinpath("dir 4", "subdir 4", "IV.txt").is_file()
assert tmp_path.joinpath("6.txt").is_file()
# Check what must not exist
assert not tmp_path.joinpath("1.txt").exists()
assert not tmp_path.joinpath("dir 2").exists()
assert not tmp_path.joinpath("dir 3", "subdir 3").exists()
assert not tmp_path.joinpath("dir 4", "subdir 4", "4.txt").exists()
assert not tmp_path.joinpath("dir 5").exists()