Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: include local dirty changes by default. fixes #184 #520

Merged
merged 15 commits into from
Jan 11, 2022
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Summary:
- Raise a CopierAnswersInterrupt instead of a bare KeyboardInterrupt to provide
callers with additional context - such as the partially completed AnswersMap.
- Support for `user_defaults`, which take precedence over template defaults.
- Copy dirty changes from a git-tracked template to the project by default, to make
testing easier.

### Changed

Expand Down
4 changes: 4 additions & 0 deletions copier/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,7 @@ class UnknownCopierVersionWarning(UserWarning, CopierWarning):

class OldTemplateWarning(UserWarning, CopierWarning):
"""Template was designed for an older Copier version."""


class DirtyLocalWarning(UserWarning, CopierWarning):
"""Changes and untracked files present in template."""
4 changes: 2 additions & 2 deletions copier/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,8 +426,8 @@ def templates_suffix(self) -> str:
def local_abspath(self) -> Path:
"""Get the absolute path to the template on disk.

This may clone it if `url` points to a
VCS-tracked template.
This may clone it if `url` points to a VCS-tracked template.
Dirty changes for local VCS-tracked templates will be copied.
"""
result = Path(self.url)
if self.vcs == "git":
Expand Down
30 changes: 30 additions & 0 deletions copier/vcs.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""Utilities related to VCS."""
import os
import re
import sys
import tempfile
from pathlib import Path
from warnings import warn

from packaging import version
from packaging.version import Version
from plumbum import TF, ProcessExecutionError, colors, local
from plumbum.cmd import git

from .errors import DirtyLocalWarning
from .tools import TemporaryDirectory
from .types import OptBool, OptStr, StrOrPath

Expand Down Expand Up @@ -111,20 +114,47 @@ def checkout_latest_tag(local_repo: StrOrPath, use_prereleases: OptBool = False)
def clone(url: str, ref: OptStr = None) -> str:
"""Clone repo into some temporary destination.

Includes dirty changes for local templates by copying into a temp
directory and applying a wip commit there.

Args:
url:
Git-parseable URL of the repo. As returned by
[get_repo][copier.vcs.get_repo].
ref:
Reference to checkout. For Git repos, defaults to `HEAD`.
"""

location = tempfile.mkdtemp(prefix=f"{__name__}.clone.")
_clone = git["clone", "--no-checkout", url, location]
# Faster clones if possible
if GIT_VERSION >= Version("2.27"):
_clone = _clone["--filter=blob:none"]
_clone()

if not ref and os.path.exists(url) and Path(url).is_dir():
is_dirty = False
with local.cwd(url):
is_dirty = bool(git("status", "--porcelain").strip())
if is_dirty:
url_abspath = Path(url).absolute()
with local.cwd(location):
git("--git-dir=.git", f"--work-tree={url_abspath}", "add", "-A")
git(
"--git-dir=.git",
f"--work-tree={url_abspath}",
"commit",
"-m",
"Copier automated commit for draft changes",
"--no-verify",
)
warn(
"Dirty template changes included automatically.",
DirtyLocalWarning,
)

with local.cwd(location):
git("checkout", ref or "HEAD")
git("submodule", "update", "--checkout", "--init", "--recursive", "--force")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got the feeling that this block should be done before importing the ditty changes. However, if the test goes green I guess I'm just wrong.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it shouldn't matter if it runs before or after. But if there is no HEAD in the case of a recently intialized repo, then this fails and would need to be called after as well. I also realized that specifying ref means that dirty changes won't be considered and so added that to the dirty checking logic.


return location
116 changes: 116 additions & 0 deletions tests/test_dirty_local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import os
from pathlib import Path
from shutil import copy2, copytree

import pytest
from plumbum import local
from plumbum.cmd import git

import copier
from copier.errors import DirtyLocalWarning
from copier.main import run_copy, run_update

from .helpers import DATA, PROJECT_TEMPLATE, build_file_tree, filecmp


def test_copy(tmp_path_factory):
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))

# dirs_exist_ok not available in Python 3.7
for item in os.listdir(PROJECT_TEMPLATE):
item_src_path = os.path.join(PROJECT_TEMPLATE, item)
item_dst_path = os.path.join(src, item)
if os.path.isdir(item_src_path):
copytree(item_src_path, item_dst_path)
else:
copy2(item_src_path, item_dst_path)

with local.cwd(src):
git("init")

with pytest.warns(DirtyLocalWarning):
sabard marked this conversation as resolved.
Show resolved Hide resolved
copier.copy(str(src), str(dst), data=DATA, quiet=True)

generated = (dst / "pyproject.toml").read_text()
control = (Path(__file__).parent / "reference_files" / "pyproject.toml").read_text()
assert generated == control

# assert template still dirty
with local.cwd(src):
assert bool(git("status", "--porcelain").strip())


def test_update(tmp_path_factory):
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))

build_file_tree(
{
src
/ ".copier-answers.yml.jinja": """\
# Changes here will be overwritten by Copier
{{ _copier_answers|to_nice_yaml }}
""",
src
/ "copier.yml": """\
_envops:
"keep_trailing_newline": True
""",
src
/ "aaaa.txt": """
Lorem ipsum
""",
src
/ "to_delete.txt": """
yajo marked this conversation as resolved.
Show resolved Hide resolved
delete me.
""",
}
)

with local.cwd(src):
git("init")
git("add", "-A")
git("commit", "-m", "first commit on src")

run_copy(str(src), dst, defaults=True, overwrite=True)

with local.cwd(src):
# test adding a file
with open("test_file.txt", "w") as f:
f.write("Test content")

# test updating a file
with open("aaaa.txt", "a") as f:
f.write("dolor sit amet")

# test removing a file
os.remove("to_delete.txt")

# dst must be vcs-tracked to use run_update
with local.cwd(dst):
git("init")
git("add", "-A")
git("commit", "-m", "first commit on dst")

# make sure changes have not yet propagated
assert not os.path.exists(dst / "test_file.txt")

p1 = str(src / "aaaa.txt")
p2 = str(dst / "aaaa.txt")
assert not filecmp.cmp(p1, p2)

assert os.path.exists(dst / "to_delete.txt")

with pytest.warns(DirtyLocalWarning):
run_update(dst, defaults=True, overwrite=True)

# make sure changes propagate after update
assert os.path.exists(dst / "test_file.txt")

p1 = str(src / "aaaa.txt")
p2 = str(dst / "aaaa.txt")
filecmp.clear_cache()
assert filecmp.cmp(p1, p2)
yajo marked this conversation as resolved.
Show resolved Hide resolved

# HACK https://github.com/copier-org/copier/issues/461
# TODO test file deletion on update
# assert not os.path.exists(dst / "to_delete.txt")