mirror of
https://github.com/copier-org/copier.git
synced 2025-05-05 15:32:54 +00:00
1924 lines
62 KiB
Python
1924 lines
62 KiB
Python
from __future__ import annotations
|
|
|
|
import platform
|
|
from pathlib import Path
|
|
from shutil import rmtree
|
|
from textwrap import dedent
|
|
from typing import Literal
|
|
|
|
import pexpect
|
|
import pytest
|
|
from plumbum import local
|
|
|
|
from copier._cli import CopierApp
|
|
from copier._main import Worker, run_copy, run_update
|
|
from copier._tools import normalize_git_path
|
|
from copier._user_data import load_answersfile_data
|
|
from copier.errors import UserMessageError
|
|
|
|
from .helpers import (
|
|
BRACKET_ENVOPS_JSON,
|
|
COPIER_CMD,
|
|
COPIER_PATH,
|
|
SUFFIX_TMPL,
|
|
Spawn,
|
|
build_file_tree,
|
|
git,
|
|
git_init,
|
|
)
|
|
|
|
|
|
@pytest.mark.impure
|
|
def test_updatediff(tmp_path_factory: pytest.TempPathFactory) -> None:
|
|
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
|
|
when: "{{ _stage == 'before' }}"
|
|
command: touch before-v0.0.1
|
|
- version: v0.0.1
|
|
when: "{{ _stage == 'after' }}"
|
|
command: touch after-v0.0.1
|
|
- version: v0.0.2
|
|
when: "{{ _stage == 'before' }}"
|
|
command: touch before-v0.0.2
|
|
- version: v0.0.2
|
|
when: "{{ _stage == 'after' }}"
|
|
command: touch after-v0.0.2
|
|
- version: v1.0.0
|
|
when: "{{ _stage == 'before' }}"
|
|
command: touch before-v1.0.0
|
|
- version: v1.0.0
|
|
when: "{{ _stage == 'after' }}"
|
|
command: 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
|
|
when: "{{ _stage == 'before' }}"
|
|
command: touch before-v0.0.1
|
|
- version: v0.0.1
|
|
when: "{{ _stage == 'after' }}"
|
|
command: touch after-v0.0.1
|
|
- version: v0.0.2
|
|
when: "{{ _stage == 'before' }}"
|
|
command: touch before-v0.0.2
|
|
- version: v0.0.2
|
|
when: "{{ _stage == 'after' }}"
|
|
command: touch after-v0.0.2
|
|
- version: v1.0.0
|
|
when: "{{ _stage == 'before' }}"
|
|
command: touch before-v1.0.0
|
|
- version: v1.0.0
|
|
when: "{{ _stage == 'after' }}"
|
|
command: 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"
|
|
commit = git["commit", "--all"]
|
|
# Run copier 1st time, with specific tag
|
|
CopierApp.run(
|
|
[
|
|
"copier",
|
|
"copy",
|
|
str(bundle),
|
|
str(target),
|
|
"--defaults",
|
|
"--overwrite",
|
|
"--vcs-ref=v0.0.1",
|
|
],
|
|
exit=False,
|
|
)
|
|
# Check it's copied OK
|
|
assert load_answersfile_data(target) == {
|
|
"_commit": "v0.0.1",
|
|
"_src_path": str(bundle),
|
|
"author_name": "Guybrush",
|
|
"project_name": "to become a pirate",
|
|
}
|
|
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")
|
|
# Commit changes
|
|
git("add", ".")
|
|
commit("-m", "hello world")
|
|
# Emulate the user modifying the README by hand
|
|
readme.write_text(
|
|
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.run(["copier", "update", "--defaults", "--UNSAFE"], exit=False)
|
|
assert load_answersfile_data(target) == {
|
|
"_commit": "v0.0.2",
|
|
"_src_path": str(bundle),
|
|
"author_name": "Guybrush",
|
|
"project_name": "to become a pirate",
|
|
}
|
|
# 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.run(
|
|
["copier", "update", "--defaults", "--vcs-ref=HEAD"],
|
|
exit=False,
|
|
)
|
|
# 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 load_answersfile_data(target) == {
|
|
"_commit": last_commit,
|
|
"_src_path": str(bundle),
|
|
"author_name": "Guybrush",
|
|
"project_name": "to become a pirate",
|
|
}
|
|
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.run(
|
|
["copier", "update", "--defaults", "--vcs-ref=HEAD"],
|
|
exit=False,
|
|
)
|
|
assert not git("status", "--porcelain")
|
|
# If I change an option, it updates properly
|
|
run_update(
|
|
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: pytest.TempPathFactory) -> None:
|
|
"""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 -t pre-commit -t commit-msg
|
|
- 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: https://github.com/commitizen-tools/commitizen
|
|
rev: v3.12.0
|
|
hooks:
|
|
- id: commitizen
|
|
- 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", "feat: commit 1")
|
|
git("tag", "v1")
|
|
# Copy source template
|
|
run_copy(
|
|
src_path=str(src), dst_path=dst1, defaults=True, overwrite=True, unsafe=True
|
|
)
|
|
with local.cwd(dst1):
|
|
life = Path("life.yml")
|
|
git("add", ".")
|
|
# 1st commit fails because pre-commit reformats life.yml
|
|
git("commit", "-am", "feat: failed commit", retcode=1)
|
|
# 2nd commit works because it's already formatted
|
|
git("commit", "-am", "feat: 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", "feat: commit 2")
|
|
git("tag", "v2")
|
|
# Update subproject to v2
|
|
run_update(
|
|
dst_path=dst1,
|
|
defaults=True,
|
|
overwrite=True,
|
|
conflict="rej",
|
|
context_lines=1,
|
|
unsafe=True,
|
|
)
|
|
with local.cwd(dst1):
|
|
git("commit", "-am", "feat: 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", "chore: 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
|
|
run_update(
|
|
data={"what": "study"},
|
|
defaults=True,
|
|
overwrite=True,
|
|
conflict="rej",
|
|
context_lines=1,
|
|
unsafe=True,
|
|
)
|
|
git("commit", "-am", "chore: 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(tmp_path_factory: pytest.TempPathFactory) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
# Build a template
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}",
|
|
"example": "1",
|
|
}
|
|
)
|
|
git("init")
|
|
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), dst_path=dst)
|
|
example = dst / "example"
|
|
assert example.read_text() == "1"
|
|
assert load_answersfile_data(dst)["_commit"] == "v1"
|
|
# Build repo on copy
|
|
with local.cwd(dst):
|
|
git("init")
|
|
git("add", "-A")
|
|
git("commit", "-m3")
|
|
# Update project, it must let us do it
|
|
run_update(dst, vcs_ref="HEAD", defaults=True, overwrite=True)
|
|
assert example.read_text() == "2"
|
|
assert load_answersfile_data(dst)["_commit"] == f"v1-1-g{sha}"
|
|
|
|
|
|
def test_skip_update(tmp_path_factory: pytest.TempPathFactory) -> None:
|
|
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 = load_answersfile_data(dst)
|
|
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 = load_answersfile_data(dst)
|
|
assert skip_me.read_text() == "2"
|
|
assert answers["_commit"] == "2.0.0"
|
|
assert not (dst / "skip_me.rej").exists()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"file_name",
|
|
(
|
|
"skip_normal_file",
|
|
pytest.param(
|
|
"skip_unicode_âñ",
|
|
marks=pytest.mark.xfail(
|
|
platform.system() in {"Darwin", "Windows"},
|
|
reason="OS without proper UTF-8 filesystem.",
|
|
),
|
|
),
|
|
"skip file with whitespace",
|
|
" skip_leading_whitespace",
|
|
"skip_trailing_whitespace ",
|
|
" skip_multi_whitespace ",
|
|
pytest.param(
|
|
"\tskip_other_whitespace\t\\t",
|
|
marks=pytest.mark.skipif(
|
|
platform.system() == "Windows",
|
|
reason="Disallowed characters in file name",
|
|
),
|
|
),
|
|
pytest.param(
|
|
"\a\f\n\t\vskip_control\a\f\n\t\vcharacters\v\t\n\f\a",
|
|
marks=pytest.mark.skipif(
|
|
platform.system() == "Windows",
|
|
reason="Disallowed characters in file name",
|
|
),
|
|
),
|
|
pytest.param(
|
|
"skip_back\\slash",
|
|
marks=pytest.mark.skipif(
|
|
platform.system() == "Windows",
|
|
reason="Disallowed characters in file name",
|
|
),
|
|
),
|
|
pytest.param(
|
|
"!skip_special",
|
|
marks=pytest.mark.skipif(
|
|
platform.system() == "Windows",
|
|
reason="Disallowed characters in file name",
|
|
),
|
|
),
|
|
),
|
|
)
|
|
def test_skip_update_deleted(
|
|
file_name: str, tmp_path_factory: pytest.TempPathFactory
|
|
) -> None:
|
|
"""
|
|
Ensure that paths in ``skip_if_exists`` are always recreated
|
|
if they are absent before updating.
|
|
"""
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"copier.yaml": "_skip_if_exists: ['*skip*']",
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
|
|
file_name: "1",
|
|
"another_file": "foobar",
|
|
}
|
|
)
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
git("tag", "1.0.0")
|
|
run_copy(str(src), dst, defaults=True, overwrite=True)
|
|
skip_me = dst / file_name
|
|
answers = load_answersfile_data(dst)
|
|
assert skip_me.read_text() == "1"
|
|
assert answers["_commit"] == "1.0.0"
|
|
skip_me.unlink()
|
|
with local.cwd(dst):
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
run_update(dst, overwrite=True)
|
|
assert skip_me.exists()
|
|
assert skip_me.read_text() == "1"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"file_name",
|
|
(
|
|
"normal_file",
|
|
pytest.param(
|
|
"unicode_âñ",
|
|
marks=pytest.mark.xfail(
|
|
platform.system() in {"Darwin", "Windows"},
|
|
reason="OS without proper UTF-8 filesystem.",
|
|
),
|
|
),
|
|
"file with whitespace",
|
|
" leading_whitespace",
|
|
"trailing_whitespace ",
|
|
" multi_whitespace ",
|
|
pytest.param(
|
|
"\tother_whitespace\t\\t",
|
|
marks=pytest.mark.skipif(
|
|
platform.system() == "Windows",
|
|
reason="Disallowed characters in file name",
|
|
),
|
|
),
|
|
pytest.param(
|
|
# This param accounts for some limitations that would
|
|
# otherwise make the test fail:
|
|
# * \r in path segment names is converted to \n by Jinja rendering,
|
|
# hence the rendered file would be named differently altogether.
|
|
# * The pathspec lib does not account for different kinds of escaped
|
|
# whitespace at the end of the pattern, only a space.
|
|
# If there are control characters at the end of the string
|
|
# that would be stripped by .strip(), the pattern would end
|
|
# in the backslash that should have escaped it.
|
|
"\a\f\n\t\vcontrol\a\f\n\t\vcharacters\v\t\n\f\a",
|
|
marks=pytest.mark.skipif(
|
|
platform.system() == "Windows",
|
|
reason="Disallowed characters in file name",
|
|
),
|
|
),
|
|
pytest.param(
|
|
"back\\slash",
|
|
marks=pytest.mark.skipif(
|
|
platform.system() == "Windows",
|
|
reason="Disallowed characters in file name",
|
|
),
|
|
),
|
|
pytest.param(
|
|
"!special",
|
|
marks=pytest.mark.skipif(
|
|
platform.system() == "Windows",
|
|
reason="Disallowed characters in file name",
|
|
),
|
|
),
|
|
pytest.param(
|
|
"dont_wildmatch*",
|
|
marks=pytest.mark.skipif(
|
|
platform.system() == "Windows",
|
|
reason="Disallowed characters in file name",
|
|
),
|
|
),
|
|
),
|
|
)
|
|
def test_update_deleted_path(
|
|
file_name: str, tmp_path_factory: pytest.TempPathFactory
|
|
) -> None:
|
|
"""
|
|
Ensure that deleted paths are not regenerated during updates,
|
|
even if the template has changes in that path.
|
|
"""
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
|
|
file_name: "foo",
|
|
"another_file": "foobar",
|
|
"dont_wildmatch": "bar",
|
|
}
|
|
)
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
git("tag", "1.0.0")
|
|
run_copy(str(src), dst, defaults=True, overwrite=True)
|
|
updated_file = dst / file_name
|
|
dont_wildmatch = dst / "dont_wildmatch"
|
|
answers = load_answersfile_data(dst)
|
|
assert dont_wildmatch.read_text() == "bar"
|
|
assert updated_file.read_text() == "foo"
|
|
assert answers["_commit"] == "1.0.0"
|
|
updated_file.unlink()
|
|
with local.cwd(dst):
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
with local.cwd(src):
|
|
build_file_tree({file_name: "bar", "dont_wildmatch": "baz"})
|
|
git("commit", "-am2")
|
|
git("tag", "2.0.0")
|
|
run_update(dst, overwrite=True)
|
|
assert dont_wildmatch.exists()
|
|
assert dont_wildmatch.read_text() == "baz"
|
|
assert not updated_file.exists()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"answers_file", [None, ".copier-answers.yml", ".custom.copier-answers.yaml"]
|
|
)
|
|
def test_overwrite_answers_file_always(
|
|
tmp_path_factory: pytest.TempPathFactory, answers_file: str | None
|
|
) -> None:
|
|
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), 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, overwrite=True, answers_file=answers_file)
|
|
answers = load_answersfile_data(dst, answers_file or ".copier-answers.yml")
|
|
assert answers["question_1"] is True
|
|
assert answers["_commit"] == "2"
|
|
assert (dst / "answer_1").read_text() == "True"
|
|
|
|
|
|
def test_file_removed(tmp_path_factory: pytest.TempPathFactory) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
# Add a file in the template repo
|
|
with local.cwd(src):
|
|
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",
|
|
"toignore.txt": "should survive update",
|
|
}
|
|
)
|
|
git("init")
|
|
git("add", "-A")
|
|
git("commit", "-m1")
|
|
git("tag", "1")
|
|
# Copy in subproject
|
|
with local.cwd(dst):
|
|
git("init")
|
|
run_copy(str(src))
|
|
# 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 (dst / ".copier-answers.yml").is_file()
|
|
assert (dst / "1.txt").is_file()
|
|
assert (dst / "dir 2" / "2.txt").is_file()
|
|
assert (dst / "dir 3" / "subdir 3" / "3.txt").is_file()
|
|
assert (dst / "dir 4" / "subdir 4" / "4.txt").is_file()
|
|
assert (dst / "dir 5" / "subdir 5" / "5.txt").is_file()
|
|
assert (dst / "toignore.txt").is_file()
|
|
assert (dst / "I.txt").is_file()
|
|
assert (dst / "dir II" / "II.txt").is_file()
|
|
assert (dst / "dir 3" / "subdir III" / "III.txt").is_file()
|
|
assert (dst / "dir 4" / "subdir 4" / "IV.txt").is_file()
|
|
# Template removes file 1
|
|
with local.cwd(src):
|
|
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(dst):
|
|
Path(".gitignore").write_text("toignore.txt")
|
|
git("add", ".gitignore")
|
|
git("commit", "-m", "ignore file")
|
|
with pytest.raises(
|
|
UserMessageError, match="Enable overwrite to update a subproject."
|
|
):
|
|
run_update(conflict="rej")
|
|
run_update(conflict="rej", overwrite=True)
|
|
# Check what must still exist
|
|
assert (dst / ".copier-answers.yml").is_file()
|
|
assert (dst / "I.txt").is_file()
|
|
assert (dst / "dir II" / "II.txt").is_file()
|
|
assert (dst / "dir 3" / "subdir III" / "III.txt").is_file()
|
|
assert (dst / "dir 4" / "subdir 4" / "IV.txt").is_file()
|
|
assert (dst / "6.txt").is_file()
|
|
assert (dst / "toignore.txt").is_file()
|
|
# Check what must not exist
|
|
assert not (dst / "1.txt").exists()
|
|
assert not (dst / "dir 2").exists()
|
|
assert not (dst / "dir 3" / "subdir 3").exists()
|
|
assert not (dst / "dir 4" / "subdir 4" / "4.txt").exists()
|
|
assert not (dst / "dir 5").exists()
|
|
|
|
|
|
@pytest.mark.parametrize("interactive", [True, False])
|
|
def test_update_inline_changed_answers_and_questions(
|
|
tmp_path_factory: pytest.TempPathFactory, interactive: bool, spawn: Spawn
|
|
) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
|
|
"copier.yml": "b: false",
|
|
"content.jinja": """\
|
|
aaa
|
|
{%- if b %}
|
|
bbb
|
|
{%- endif %}
|
|
zzz
|
|
""",
|
|
}
|
|
)
|
|
git("init")
|
|
git("add", "-A")
|
|
git("commit", "-m1")
|
|
git("tag", "1")
|
|
build_file_tree(
|
|
{
|
|
"copier.yml": dedent(
|
|
"""\
|
|
b: false
|
|
c: false
|
|
"""
|
|
),
|
|
"content.jinja": """\
|
|
aaa
|
|
{%- if b %}
|
|
bbb
|
|
{%- endif %}
|
|
{%- if c %}
|
|
ccc
|
|
{%- endif %}
|
|
zzz
|
|
""",
|
|
}
|
|
)
|
|
git("commit", "-am2")
|
|
git("tag", "2")
|
|
# Init project
|
|
if interactive:
|
|
tui = spawn(COPIER_PATH + ("copy", "-r1", str(src), str(dst)), timeout=10)
|
|
tui.expect_exact("b (bool)")
|
|
tui.expect_exact("(y/N)")
|
|
tui.send("y")
|
|
tui.expect_exact(pexpect.EOF)
|
|
else:
|
|
run_copy(str(src), dst, data={"b": True}, vcs_ref="1")
|
|
assert "ccc" not in (dst / "content").read_text()
|
|
with local.cwd(dst):
|
|
git("init")
|
|
git("add", "-A")
|
|
git("commit", "-m1")
|
|
# Project evolution
|
|
Path("content").write_text(
|
|
dedent(
|
|
"""\
|
|
aaa
|
|
bbb
|
|
jjj
|
|
zzz
|
|
"""
|
|
)
|
|
)
|
|
git("commit", "-am2")
|
|
# Update from template, inline, with answer changes
|
|
if interactive:
|
|
tui = spawn(COPIER_PATH + ("update", "--conflict=inline"), timeout=10)
|
|
tui.expect_exact("b (bool)")
|
|
tui.expect_exact("(Y/n)")
|
|
tui.sendline()
|
|
tui.expect_exact("c (bool)")
|
|
tui.expect_exact("(y/N)")
|
|
tui.send("y")
|
|
tui.expect_exact(pexpect.EOF)
|
|
else:
|
|
run_update(
|
|
data={"c": True}, defaults=True, overwrite=True, conflict="inline"
|
|
)
|
|
assert Path("content").read_text() == dedent(
|
|
"""\
|
|
aaa
|
|
bbb
|
|
<<<<<<< before updating
|
|
jjj
|
|
=======
|
|
ccc
|
|
>>>>>>> after updating
|
|
zzz
|
|
"""
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("conflict", ["rej", "inline"])
|
|
def test_update_in_repo_subdirectory(
|
|
tmp_path_factory: pytest.TempPathFactory, conflict: Literal["rej", "inline"]
|
|
) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
subdir = Path("subdir")
|
|
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
|
|
"version.txt": "v1",
|
|
}
|
|
)
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
git("tag", "v1")
|
|
|
|
run_copy(str(src), dst / subdir)
|
|
|
|
with local.cwd(dst):
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
|
|
assert (dst / subdir / ".copier-answers.yml").is_file()
|
|
assert (dst / subdir / "version.txt").is_file()
|
|
assert (dst / subdir / "version.txt").read_text() == "v1"
|
|
|
|
with local.cwd(dst):
|
|
build_file_tree({subdir / "version.txt": "v1 edited"})
|
|
git("add", ".")
|
|
git("commit", "-m1e")
|
|
|
|
with local.cwd(src):
|
|
build_file_tree({"version.txt": "v2"})
|
|
git("add", ".")
|
|
git("commit", "-m2")
|
|
git("tag", "v2")
|
|
|
|
run_update(dst / subdir, overwrite=True, conflict=conflict)
|
|
|
|
assert (dst / subdir / ".copier-answers.yml").is_file()
|
|
assert (dst / subdir / "version.txt").is_file()
|
|
if conflict == "rej":
|
|
assert (dst / subdir / "version.txt").read_text() == "v2"
|
|
assert (dst / subdir / "version.txt.rej").is_file()
|
|
else:
|
|
assert (dst / subdir / "version.txt").read_text() == dedent(
|
|
"""\
|
|
<<<<<<< before updating
|
|
v1 edited
|
|
=======
|
|
v2
|
|
>>>>>>> after updating
|
|
"""
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"context_lines",
|
|
[
|
|
pytest.param(
|
|
1,
|
|
marks=pytest.mark.xfail(
|
|
raises=AssertionError,
|
|
reason="Not enough context lines to resolve the conflict.",
|
|
strict=True,
|
|
),
|
|
),
|
|
pytest.param(
|
|
2,
|
|
marks=pytest.mark.xfail(
|
|
raises=AssertionError,
|
|
reason="Not enough context lines to resolve the conflict.",
|
|
strict=True,
|
|
),
|
|
),
|
|
3,
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("api", [True, False])
|
|
def test_update_needs_more_context(
|
|
tmp_path_factory: pytest.TempPathFactory, context_lines: int, api: bool
|
|
) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
# Create a template where some code blocks are similar
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
|
|
"sample.py": dedent(
|
|
"""\
|
|
def function_one():
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("Previous line lied.")
|
|
|
|
def function_two():
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("Previous line lied.")
|
|
"""
|
|
),
|
|
}
|
|
)
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
git("tag", "v1")
|
|
# Render and evolve that project
|
|
with local.cwd(dst):
|
|
if api:
|
|
run_copy(str(src), ".")
|
|
else:
|
|
CopierApp.run(["copier", "copy", str(src), "."], exit=False)
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
build_file_tree(
|
|
{
|
|
"sample.py": dedent(
|
|
"""\
|
|
def function_one():
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("Previous line lied.")
|
|
|
|
def function_two():
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is new.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("Previous line lied.")
|
|
"""
|
|
)
|
|
}
|
|
)
|
|
git("commit", "-am2")
|
|
# Evolve the template
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"sample.py": dedent(
|
|
"""\
|
|
def function_zero():
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("Previous line lied.")
|
|
|
|
def function_one():
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("Previous line lied.")
|
|
|
|
def function_two():
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("Previous line lied.")
|
|
"""
|
|
)
|
|
}
|
|
)
|
|
git("commit", "-am3")
|
|
git("tag", "v2")
|
|
# Update the project
|
|
if api:
|
|
run_update(dst, overwrite=True, conflict="inline", context_lines=context_lines)
|
|
else:
|
|
COPIER_CMD(
|
|
"update",
|
|
str(dst),
|
|
"--conflict=inline",
|
|
f"--context-lines={context_lines}",
|
|
)
|
|
# Check the update result
|
|
assert (dst / "sample.py").read_text() == dedent(
|
|
"""\
|
|
def function_zero():
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("Previous line lied.")
|
|
|
|
def function_one():
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("Previous line lied.")
|
|
|
|
def function_two():
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is new.")
|
|
print("This line is equal to the next one.")
|
|
print("This line is equal to the next one.")
|
|
print("Previous line lied.")
|
|
"""
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"filename",
|
|
[
|
|
"README.md",
|
|
"spa ces",
|
|
# Double quotes are not supported in file names on Windows.
|
|
"qu`o'tes" if platform.system() == "Windows" else 'qu`o"tes',
|
|
"m4â4ñ4a",
|
|
],
|
|
)
|
|
def test_conflicted_files_are_marked_unmerged(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
filename: str,
|
|
) -> 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"))
|
|
|
|
# First, create the template with an initial file
|
|
build_file_tree(
|
|
{
|
|
(src / filename): "upstream version 1",
|
|
(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 load_answersfile_data(dst).get("_commit") == "v1"
|
|
|
|
# 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 load_answersfile_data(dst).get("_commit") == "v2"
|
|
|
|
# Assert that the file still exists, has inline conflict markers,
|
|
# and is reported as "unmerged" by Git.
|
|
assert (dst / filename).exists()
|
|
|
|
expected_contents = dedent(
|
|
"""\
|
|
<<<<<<< before updating
|
|
upstream version 1 + downstream
|
|
=======
|
|
upstream version 2
|
|
>>>>>>> after updating
|
|
"""
|
|
)
|
|
assert (dst / filename).read_text().splitlines() == expected_contents.splitlines()
|
|
assert not (dst / f"{filename}.rej").exists()
|
|
|
|
with local.cwd(dst):
|
|
lines = git("status", "--porcelain=v1").strip().splitlines()
|
|
assert any(
|
|
line.startswith("UU") and normalize_git_path(line[3:]) == filename
|
|
for line in lines
|
|
)
|
|
|
|
|
|
def test_3way_merged_files_without_conflicts_are_not_marked_unmerged(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> None:
|
|
filename = "readme.md"
|
|
|
|
# Template in v1 has a file with a single line;
|
|
# in v2 it changes that line.
|
|
# Meanwhile, downstream project made the same change.
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
|
|
# First, create the template with an initial file
|
|
build_file_tree(
|
|
{
|
|
(src / filename): "upstream version 1",
|
|
(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 load_answersfile_data(dst).get("_commit") == "v1"
|
|
|
|
# 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 2")
|
|
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 load_answersfile_data(dst).get("_commit") == "v2"
|
|
|
|
# Assert that the file still exists, does not have inline conflict markers,
|
|
# and is not reported as "unmerged" by Git.
|
|
assert (dst / filename).exists()
|
|
|
|
expected_contents = "upstream version 2"
|
|
assert (dst / filename).read_text() == expected_contents
|
|
assert not (dst / f"{filename}.rej").exists()
|
|
|
|
with local.cwd(dst):
|
|
lines = git("status", "--porcelain=v1").strip().splitlines()
|
|
assert not any(
|
|
line.startswith("UU") and normalize_git_path(line[3:]) == filename
|
|
for line in lines
|
|
)
|
|
|
|
|
|
def test_update_with_new_file_in_template_and_project(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"{{ _copier_conf.answers_file }}.jinja": (
|
|
"{{ _copier_answers|to_yaml }}"
|
|
),
|
|
}
|
|
)
|
|
git_init("v1")
|
|
git("tag", "v1")
|
|
|
|
run_copy(str(src), dst, defaults=True, overwrite=True)
|
|
assert load_answersfile_data(dst).get("_commit") == "v1"
|
|
|
|
with local.cwd(dst):
|
|
git_init("v1")
|
|
Path(".gitlab-ci.yml").write_text(
|
|
dedent(
|
|
"""\
|
|
tests:
|
|
stage: test
|
|
script:
|
|
- ./test.sh
|
|
|
|
pages:
|
|
stage: deploy
|
|
script:
|
|
- ./deploy.sh
|
|
"""
|
|
)
|
|
)
|
|
git("add", ".")
|
|
git("commit", "-m", "v2")
|
|
|
|
with local.cwd(src):
|
|
Path(".gitlab-ci.yml.jinja").write_text(
|
|
dedent(
|
|
"""\
|
|
tests:
|
|
stage: test
|
|
script:
|
|
- ./test.sh --slow
|
|
"""
|
|
)
|
|
)
|
|
git("add", ".")
|
|
git("commit", "-m", "v2")
|
|
git("tag", "v2")
|
|
|
|
run_update(dst_path=dst, defaults=True, overwrite=True, conflict="inline")
|
|
assert load_answersfile_data(dst).get("_commit") == "v2"
|
|
assert (dst / ".gitlab-ci.yml").read_text() == dedent(
|
|
"""\
|
|
tests:
|
|
stage: test
|
|
script:
|
|
<<<<<<< before updating
|
|
- ./test.sh
|
|
|
|
pages:
|
|
stage: deploy
|
|
script:
|
|
- ./deploy.sh
|
|
=======
|
|
- ./test.sh --slow
|
|
>>>>>>> after updating
|
|
"""
|
|
)
|
|
|
|
|
|
def test_update_with_new_file_in_template_and_project_via_migration(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> None:
|
|
"""Merge conflicts are yielded when both template and project add same file.
|
|
|
|
The project adds content to `.gitlab-ci.yml` on top of what template v1 provides.
|
|
In a template v2, `.gitlab-ci.yml.jinja` is moved to `.gitlab/ci/main.yml.jinja`
|
|
and `.gitlab-ci.yml.jinja` now includes the generated `.gitlab/ci/main.yml`. To
|
|
retain the project's changes/additions to `.gitlab-ci.yml`, a pre-update migration
|
|
task copies `.gitlab-ci.yml` (containing those changes/additions) to
|
|
`.gitlab/ci/main.yml` and stages it, then Copier applies template v2's version of
|
|
that file (which was also moved there, but Git doesn't recognize it as status `R`
|
|
but as `A`).
|
|
"""
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"{{ _copier_conf.answers_file }}.jinja": (
|
|
"{{ _copier_answers|to_yaml }}"
|
|
),
|
|
".gitlab-ci.yml.jinja": (
|
|
"""\
|
|
tests:
|
|
stage: test
|
|
script:
|
|
- ./test.sh
|
|
"""
|
|
),
|
|
}
|
|
)
|
|
git_init("v1")
|
|
git("tag", "v1")
|
|
|
|
run_copy(str(src), dst, defaults=True, overwrite=True)
|
|
assert load_answersfile_data(dst).get("_commit") == "v1"
|
|
assert (dst / ".gitlab-ci.yml").exists()
|
|
|
|
with local.cwd(dst):
|
|
with Path(".gitlab-ci.yml").open(mode="at") as f:
|
|
f.write(
|
|
dedent(
|
|
"""\
|
|
|
|
pages:
|
|
stage: deploy
|
|
script:
|
|
- ./deploy.sh
|
|
"""
|
|
)
|
|
)
|
|
git_init("v1")
|
|
|
|
with local.cwd(src):
|
|
old_file = Path(".gitlab-ci.yml.jinja")
|
|
new_file = Path(".gitlab", "ci", "main.yml.jinja")
|
|
new_file.parent.mkdir(parents=True)
|
|
# Move `.gitlab-ci.yml.jinja` to `.gitlab/ci/main.yml.jinja`
|
|
git("mv", old_file, new_file)
|
|
# Make a small modification in `.gitlab/ci/main.yml.jinja`
|
|
new_file.write_text(new_file.read_text().replace("test.sh", "test.sh --slow"))
|
|
# Include `.gitlab/ci/main.yml.jinja` in `.gitlab-ci.yml.jinja`
|
|
old_file.write_text(
|
|
dedent(
|
|
"""\
|
|
include:
|
|
- local: .gitlab/ci/main.yml
|
|
"""
|
|
)
|
|
)
|
|
# Add a pre-migration that copies `.gitlab-ci.yml` to
|
|
# `.gitlab/ci/main.yml` and stages it, so that the user changes made in
|
|
# the project are retained after moving the file.
|
|
build_file_tree(
|
|
{
|
|
"copier.yml": (
|
|
"""\
|
|
_migrations:
|
|
- version: v2
|
|
when: "{{ _stage == 'before' }}"
|
|
command: "{{ _copier_python }} {{ _copier_conf.src_path / 'migrate.py' }}"
|
|
"""
|
|
),
|
|
"migrate.py": (
|
|
"""\
|
|
from pathlib import Path
|
|
from plumbum.cmd import git
|
|
|
|
f = Path(".gitlab", "ci", "main.yml")
|
|
f.parent.mkdir(parents=True)
|
|
f.write_text(Path(".gitlab-ci.yml").read_text())
|
|
git("add", f)
|
|
"""
|
|
),
|
|
}
|
|
)
|
|
git("add", ".")
|
|
git("commit", "-m", "v2")
|
|
git("tag", "v2")
|
|
|
|
run_update(
|
|
dst_path=dst, defaults=True, overwrite=True, conflict="inline", unsafe=True
|
|
)
|
|
assert load_answersfile_data(dst).get("_commit") == "v2"
|
|
assert (dst / ".gitlab-ci.yml").read_text() == dedent(
|
|
"""\
|
|
<<<<<<< before updating
|
|
tests:
|
|
stage: test
|
|
script:
|
|
- ./test.sh
|
|
|
|
pages:
|
|
stage: deploy
|
|
script:
|
|
- ./deploy.sh
|
|
=======
|
|
include:
|
|
- local: .gitlab/ci/main.yml
|
|
>>>>>>> after updating
|
|
"""
|
|
)
|
|
assert (dst / ".gitlab" / "ci" / "main.yml").read_text() == dedent(
|
|
"""\
|
|
tests:
|
|
stage: test
|
|
script:
|
|
<<<<<<< before updating
|
|
- ./test.sh
|
|
|
|
pages:
|
|
stage: deploy
|
|
script:
|
|
- ./deploy.sh
|
|
=======
|
|
- ./test.sh --slow
|
|
>>>>>>> after updating
|
|
"""
|
|
)
|
|
|
|
|
|
def test_update_with_separate_git_directory(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> None:
|
|
src, dst, dst_git_dir = map(tmp_path_factory.mktemp, ("src", "dst", "dst_git_dir"))
|
|
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"version.txt": "v1",
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}",
|
|
}
|
|
)
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
git("tag", "v1")
|
|
|
|
run_copy(str(src), dst, overwrite=True)
|
|
assert load_answersfile_data(dst).get("_commit") == "v1"
|
|
|
|
with local.cwd(dst):
|
|
git("init", "--separate-git-dir", dst_git_dir)
|
|
# Add a file to make sure the subproject's tree object is different from
|
|
# that of the fresh copy from the old template version; otherwise, we
|
|
# cannot test the linking of local (temporary) repositories for
|
|
# borrowing Git objects.
|
|
build_file_tree({"foo.txt": "bar"})
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
|
|
with local.cwd(src):
|
|
build_file_tree({"version.txt": "v2"})
|
|
git("add", ".")
|
|
git("commit", "-m2")
|
|
git("tag", "v2")
|
|
|
|
run_update(dst, overwrite=True)
|
|
assert load_answersfile_data(dst).get("_commit") == "v2"
|
|
|
|
|
|
def test_update_with_skip_answered_and_new_answer(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"copier.yml": "boolean: false",
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}",
|
|
}
|
|
)
|
|
git_init("v1")
|
|
git("tag", "v1")
|
|
|
|
run_copy(str(src), dst, defaults=True, overwrite=True)
|
|
answers = load_answersfile_data(dst)
|
|
assert answers["boolean"] is False
|
|
|
|
with local.cwd(dst):
|
|
git_init("v1")
|
|
|
|
run_update(dst, data={"boolean": "true"}, skip_answered=True, overwrite=True)
|
|
answers = load_answersfile_data(dst)
|
|
assert answers["boolean"] is True
|
|
|
|
|
|
def test_update_dont_validate_computed_value(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"copier.yml": dedent(
|
|
"""\
|
|
computed:
|
|
type: str
|
|
default: foo
|
|
when: false
|
|
validator: "This validator should never be rendered"
|
|
"""
|
|
),
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}",
|
|
}
|
|
)
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
git("tag", "v1")
|
|
|
|
run_copy(str(src), dst, overwrite=True)
|
|
answers = load_answersfile_data(dst)
|
|
assert "computed" not in answers
|
|
|
|
with local.cwd(dst):
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
|
|
run_update(dst, overwrite=True)
|
|
answers = load_answersfile_data(dst)
|
|
assert "computed" not in answers
|
|
|
|
|
|
def test_update_git_submodule(tmp_path_factory: pytest.TempPathFactory) -> None:
|
|
src, dst, submodule = map(tmp_path_factory.mktemp, ("src", "dst", "submodule"))
|
|
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
|
|
"version.txt": "v1",
|
|
}
|
|
)
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
git("tag", "v1")
|
|
|
|
run_copy(str(src), submodule)
|
|
|
|
with local.cwd(submodule):
|
|
git("init")
|
|
git("add", ".")
|
|
git("commit", "-m1")
|
|
|
|
assert (submodule / ".copier-answers.yml").is_file()
|
|
assert (submodule / "version.txt").is_file()
|
|
assert (submodule / "version.txt").read_text() == "v1"
|
|
|
|
with local.cwd(dst):
|
|
git("init")
|
|
# See https://github.com/git/git/security/advisories/GHSA-3wp6-j8xr-qw85
|
|
# for more details on why we need to set `protocol.file.allow=always` to
|
|
# be able to clone a local submodule.
|
|
git("-c", "protocol.file.allow=always", "submodule", "add", submodule, "sub")
|
|
git("add", ".")
|
|
git("commit", "-m", "add submodule")
|
|
|
|
assert (dst / "sub" / ".copier-answers.yml").is_file()
|
|
assert (dst / "sub" / "version.txt").is_file()
|
|
assert (dst / "sub" / "version.txt").read_text() == "v1"
|
|
|
|
with local.cwd(src):
|
|
build_file_tree({"version.txt": "v2"})
|
|
git("add", ".")
|
|
git("commit", "-m2")
|
|
git("tag", "v2")
|
|
|
|
run_update(dst / "sub", overwrite=True)
|
|
|
|
assert (dst / "sub" / ".copier-answers.yml").is_file()
|
|
assert (dst / "sub" / "version.txt").is_file()
|
|
assert (dst / "sub" / "version.txt").read_text() == "v2"
|
|
|
|
|
|
def test_gitignore_file_unignored(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> None:
|
|
env_file = ".env"
|
|
gitignore_file = ".gitignore"
|
|
|
|
# Template in v1 has a file with a single line;
|
|
# in v2 it changes that line.
|
|
# Meanwhile, downstream project made the same change.
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
|
|
# First, create the template with an initial file
|
|
build_file_tree(
|
|
{
|
|
(src / gitignore_file): "",
|
|
(src / env_file): "",
|
|
(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)
|
|
for f in (env_file, gitignore_file):
|
|
assert (dst / f).exists()
|
|
assert load_answersfile_data(dst).get("_commit") == "v1"
|
|
|
|
# Start versioning the generated project
|
|
with local.cwd(dst):
|
|
git_init("hello project")
|
|
|
|
# Add a file to the `.gitignore` file
|
|
with local.cwd(src):
|
|
Path(gitignore_file).write_text(env_file)
|
|
git("commit", "-am", "ignore file")
|
|
git("tag", "v2")
|
|
|
|
# Update the generated project
|
|
run_update(dst_path=dst, overwrite=True)
|
|
assert load_answersfile_data(dst).get("_commit") == "v2"
|
|
|
|
# Commit project changes.
|
|
with local.cwd(dst):
|
|
git("commit", "-am", "update to template v2")
|
|
|
|
# Remove the file previously added to `.gitignore`
|
|
with local.cwd(src):
|
|
Path(gitignore_file).write_text("")
|
|
git("commit", "-am", "un-ignore file")
|
|
git("tag", "v3")
|
|
|
|
# Update the generated project.
|
|
# This would fail if `git add` was called without the `--force` flag;
|
|
# Otherwise, it should succeed.
|
|
run_update(dst_path=dst, overwrite=True)
|
|
assert load_answersfile_data(dst).get("_commit") == "v3"
|
|
|
|
|
|
def test_update_with_answers_with_umlaut(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"copier.yml": (
|
|
"""\
|
|
umlaut:
|
|
type: str
|
|
"""
|
|
),
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}",
|
|
}
|
|
)
|
|
git_init("v1")
|
|
git("tag", "v1")
|
|
|
|
run_copy(str(src), dst, data={"umlaut": "äöü"}, overwrite=True)
|
|
answers = load_answersfile_data(dst)
|
|
assert answers["umlaut"] == "äöü"
|
|
|
|
with local.cwd(dst):
|
|
git_init("v1")
|
|
|
|
run_update(dst, skip_answered=True, overwrite=True)
|
|
answers = load_answersfile_data(dst)
|
|
assert answers["umlaut"] == "äöü"
|
|
|
|
|
|
def test_conflict_on_update_with_unicode_in_content(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
with local.cwd(src):
|
|
build_file_tree(
|
|
{
|
|
"{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_yaml }}",
|
|
"copier.yml": "b: false",
|
|
"content.jinja": """\
|
|
aaa🐍
|
|
{%- if b %}
|
|
bbb🐍
|
|
{%- endif %}
|
|
zzz🐍
|
|
""",
|
|
},
|
|
encoding="utf-8",
|
|
)
|
|
git("init")
|
|
git("add", "-A")
|
|
git("commit", "-m1")
|
|
git("tag", "1")
|
|
build_file_tree(
|
|
{
|
|
"copier.yml": dedent(
|
|
"""\
|
|
b: false
|
|
c: false
|
|
"""
|
|
),
|
|
"content.jinja": """\
|
|
aaa🐍
|
|
{%- if b %}
|
|
bbb🐍
|
|
{%- endif %}
|
|
{%- if c %}
|
|
ccc🐍
|
|
{%- endif %}
|
|
zzz🐍
|
|
""",
|
|
},
|
|
encoding="utf-8",
|
|
)
|
|
git("commit", "-am2")
|
|
git("tag", "2")
|
|
# Init project
|
|
run_copy(str(src), dst, data={"b": True}, vcs_ref="1")
|
|
assert "ccc" not in (dst / "content").read_text(encoding="utf-8")
|
|
with local.cwd(dst):
|
|
git("init")
|
|
git("add", "-A")
|
|
git("commit", "-m1")
|
|
# Project evolution
|
|
Path("content").write_text(
|
|
dedent(
|
|
"""\
|
|
aaa🐍
|
|
bbb🐍
|
|
jjj🐍
|
|
zzz🐍
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
git("commit", "-am2")
|
|
# Update from template, inline, with answer changes
|
|
run_update(data={"c": True}, defaults=True, overwrite=True, conflict="inline")
|
|
assert Path("content").read_text(encoding="utf-8") == dedent(
|
|
"""\
|
|
aaa🐍
|
|
bbb🐍
|
|
<<<<<<< before updating
|
|
jjj🐍
|
|
=======
|
|
ccc🐍
|
|
>>>>>>> after updating
|
|
zzz🐍
|
|
"""
|
|
)
|
|
|
|
|
|
def test_conditional_computed_value(tmp_path_factory: pytest.TempPathFactory) -> None:
|
|
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
|
|
|
|
build_file_tree(
|
|
{
|
|
src / "copier.yml": (
|
|
"""\
|
|
first:
|
|
type: bool
|
|
|
|
second:
|
|
type: bool
|
|
default: "{{ first }}"
|
|
when: "{{ first }}"
|
|
"""
|
|
),
|
|
src / "{{ _copier_conf.answers_file }}.jinja": (
|
|
"{{ _copier_answers|to_nice_yaml }}"
|
|
),
|
|
src / "log.txt.jinja": "{{ first }} {{ second }}",
|
|
}
|
|
)
|
|
with local.cwd(src):
|
|
git_init("v1")
|
|
git("tag", "v1")
|
|
|
|
run_copy(str(src), dst, data={"first": True}, defaults=True)
|
|
answers = load_answersfile_data(dst)
|
|
assert answers["first"] is True
|
|
assert answers["second"] is True
|
|
assert (dst / "log.txt").read_text() == "True True"
|
|
|
|
with local.cwd(dst):
|
|
git_init("v1")
|
|
|
|
run_update(dst, data={"first": False}, overwrite=True)
|
|
answers = load_answersfile_data(dst)
|
|
assert answers["first"] is False
|
|
assert "second" not in answers
|
|
assert (dst / "log.txt").read_text() == "False False"
|
|
|
|
with local.cwd(dst):
|
|
git("add", ".")
|
|
git("commit", "-m", "v2")
|
|
|
|
run_update(dst, data={"first": True}, defaults=True, overwrite=True)
|
|
answers = load_answersfile_data(dst)
|
|
assert answers["first"] is True
|
|
assert answers["second"] is True
|
|
assert (dst / "log.txt").read_text() == "True True"
|