diff --git a/copier/_main.py b/copier/_main.py index ecb3a8c5d..12e6047ec 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -70,7 +70,7 @@ VcsRef, ) from ._user_data import AnswersMap, Question, load_answersfile_data -from ._vcs import get_git, is_git_available +from ._vcs import get_git, is_git_available, is_remote_url from .errors import ( ConfigFileError, CopierAnswersInterrupt, @@ -364,6 +364,21 @@ def _answers_to_remember(self) -> Mapping[str, Any]: answers: AnyByStrDict = {} commit = self.template.commit src = self.template.url + # Check if the original path was relative + src_path = Path(self.src_path) if self.src_path else None + if src_path is None: + last_src = self.subproject.last_answers.get("_src_path") + src_path = Path(last_src) if last_src else None + was_relative = src_path is not None and not src_path.is_absolute() + + # If original was relative and it is not a remote Git repo, save as relative to the subproject root + if was_relative and src and not is_remote_url(src): + try: + src_resolved = Path(src).resolve() + dst_resolved = self.subproject.local_abspath.resolve() + src = os.path.relpath(str(src_resolved), str(dst_resolved)) + except (ValueError, OSError): + pass for key, value in (("_commit", commit), ("_src_path", src)): if value is not None: answers[key] = value diff --git a/copier/_subproject.py b/copier/_subproject.py index fbd2e5b6f..061c85eb9 100644 --- a/copier/_subproject.py +++ b/copier/_subproject.py @@ -16,7 +16,7 @@ from ._template import Template from ._types import AbsolutePath, AnyByStrDict, VCSTypes from ._user_data import load_answersfile_data -from ._vcs import get_git, is_in_git_repo +from ._vcs import get_git, is_in_git_repo, is_remote_url @dataclass @@ -76,7 +76,17 @@ def template(self) -> Template | None: last_url = self.last_answers.get("_src_path") last_ref = self.last_answers.get("_commit") if last_url: - result = Template(url=last_url, ref=last_ref) + url = last_url + if not is_remote_url(last_url): + try: + path = Path(last_url) + if not path.is_absolute(): + resolved_path = (self.local_abspath / path).resolve() + if resolved_path.is_dir(): + url = str(resolved_path) + except OSError: + pass + result = Template(url=url, ref=last_ref) self._cleanup_hooks.append(result._cleanup) return result return None diff --git a/copier/_vcs.py b/copier/_vcs.py index 2be5ee853..cdf41e1d8 100644 --- a/copier/_vcs.py +++ b/copier/_vcs.py @@ -69,6 +69,12 @@ def is_git_available() -> bool: (re.compile(r"^gl:/?(.*)$"), r"https://gitlab.com/\1.git"), ) +REMOTE_URL_PREFIXES = ("http://", "https://", "git@", "git+", "gh:", "gl:", "bb:") + + +def is_remote_url(url: str) -> bool: + return url.startswith(REMOTE_URL_PREFIXES) + def is_git_repo_root(path: StrOrPath) -> bool: """Indicate if a given path is a git repo root directory.""" diff --git a/tests/test_answersfile.py b/tests/test_answersfile.py index 5173d6c20..282016901 100644 --- a/tests/test_answersfile.py +++ b/tests/test_answersfile.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os from contextlib import AbstractContextManager, nullcontext as does_not_raise from pathlib import Path from textwrap import dedent @@ -549,3 +550,181 @@ def test_external_data_path_outside_destination_root_is_unsafe_on_update( with expected: copier.run_update(project, defaults=True, overwrite=True, unsafe=unsafe) + + + +def test_relative_template_path_stored_as_relative_when_outside_destination( + tmp_path: Path, +) -> None: + """Template path not under destination -> stored as relative.""" + root = tmp_path + template_dir = root / "template" + project_dir = root / "project" + + build_file_tree( + { + (template_dir / "{{ _copier_conf.answers_file }}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + } + ) + + project_dir.mkdir(exist_ok=True) + + old_cwd = Path.cwd() + try: + os.chdir(root) + copier.run_copy("./template", "./project", defaults=True, overwrite=True) + finally: + os.chdir(old_cwd) + + answers = load_answersfile_data(project_dir) + assert answers["_src_path"] == os.path.relpath(str(template_dir.resolve()), str(project_dir.resolve())) + + +def test_relative_template_path_stored_as_relative_when_inside_destination( + tmp_path: Path, +) -> None: + """Template path inside destination -> stored as relative.""" + root = tmp_path + project_dir = root / "project" + hidden_template_dir = project_dir / ".hidden_template" + + build_file_tree( + { + (hidden_template_dir / "{{ _copier_conf.answers_file }}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + } + ) + + project_dir.mkdir(exist_ok=True) + + old_cwd = Path.cwd() + try: + os.chdir(root) + copier.run_copy( + "./project/.hidden_template", "./project", defaults=True, overwrite=True + ) + finally: + os.chdir(old_cwd) + + answers = load_answersfile_data(project_dir) + assert answers["_src_path"] == os.path.relpath(str(hidden_template_dir.resolve()), str(project_dir.resolve())) + + +def test_absolute_template_path_stored_as_is( + tmp_path: Path, +) -> None: + """Absolute template path -> stored unchanged (skips resolution block).""" + template_dir = tmp_path / "template" + project_dir = tmp_path / "project" + + build_file_tree( + { + (template_dir / "{{ _copier_conf.answers_file }}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + } + ) + + project_dir.mkdir(exist_ok=True) + + copier.run_copy( + str(template_dir), str(project_dir), defaults=True, overwrite=True + ) + + answers = load_answersfile_data(project_dir) + assert answers["_src_path"] == str(template_dir) + + +def test_relative_template_path_resolved_relative_to_project_root( + tmp_path: Path, +) -> None: + """Template path resolved relative to project root on update, even if CWD is different.""" + root = tmp_path + template_dir = root / "template" + project_dir = root / "project" + + build_file_tree( + { + (template_dir / "{{ _copier_conf.answers_file }}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + } + ) + git_save(template_dir, tag="v1") + + project_dir.mkdir(exist_ok=True) + + # Initial copy + old_cwd = Path.cwd() + try: + os.chdir(root) + copier.run_copy("./template", "./project", defaults=True, overwrite=True) + finally: + os.chdir(old_cwd) + + answers = load_answersfile_data(project_dir) + # Verify it was saved as relative + assert answers["_src_path"] == os.path.relpath(str(template_dir.resolve()), str(project_dir.resolve())) + + # Initialize project as git repository and commit answers file + git_save(project_dir) + + # Run update from a completely different directory (so relative to CWD would fail) + other_dir = root / "other" + other_dir.mkdir(exist_ok=True) + try: + os.chdir(other_dir) + # update should succeed because template path is resolved relative to project root + copier.run_update(str(project_dir), defaults=True, overwrite=True) + finally: + os.chdir(old_cwd) + + +def test_relative_template_path_fallback_resolved_relative_to_cwd( + tmp_path: Path, +) -> None: + """Template path fallback resolved relative to CWD if not found relative to project root.""" + root = tmp_path + template_dir = root / "template" + project_dir = root / "project" + + build_file_tree( + { + (template_dir / "{{ _copier_conf.answers_file }}.jinja"): ( + "{{ _copier_answers|to_nice_yaml }}" + ), + } + ) + git_save(template_dir, tag="v1") + + project_dir.mkdir(exist_ok=True) + + # Initial copy + old_cwd = Path.cwd() + try: + os.chdir(root) + copier.run_copy("./template", "./project", defaults=True, overwrite=True) + finally: + os.chdir(old_cwd) + + # Manually modify _src_path to be "template" (which is relative to CWD 'root', + # but "project/template" does not exist). + answers_file = project_dir / ".copier-answers.yml" + content = answers_file.read_text() + # Replace relative path (e.g. "../template") with "template" + rel_path = os.path.relpath(str(template_dir.resolve()), str(project_dir.resolve())) + assert rel_path in content + answers_file.write_text(content.replace(rel_path, "template")) + + # Initialize project as git repository and commit answers file + git_save(project_dir) + + try: + os.chdir(root) + # update should succeed because fallback resolves "template" relative to CWD (root) + copier.run_update(str(project_dir), defaults=True, overwrite=True) + finally: + os.chdir(old_cwd) \ No newline at end of file