From f87fa45b94092702866a6e52b8b4ff9026979712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20S=C3=A1nchez=20Rubio?= Date: Mon, 8 Jun 2026 10:40:41 +0200 Subject: [PATCH 1/6] fix: resolver local template path --- copier/_main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/copier/_main.py b/copier/_main.py index ecb3a8c5d..9bc339bb8 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -364,6 +364,12 @@ def _answers_to_remember(self) -> Mapping[str, Any]: answers: AnyByStrDict = {} commit = self.template.commit src = self.template.url + # For local template paths, store as absolute so the path remains + # valid if the CWD changes between copy and update operations + if src: + src_path = Path(src) + if src_path.exists(): + src = str(src_path.resolve()) for key, value in (("_commit", commit), ("_src_path", src)): if value is not None: answers[key] = value From 865b47f6592f55c935eebd0b5b2e487204e9ccad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20S=C3=A1nchez=20Rubio?= Date: Tue, 9 Jun 2026 08:12:30 +0200 Subject: [PATCH 2/6] refactor: resolve local relative template path --- copier/_main.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/copier/_main.py b/copier/_main.py index 9bc339bb8..25998ab1d 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -364,12 +364,20 @@ def _answers_to_remember(self) -> Mapping[str, Any]: answers: AnyByStrDict = {} commit = self.template.commit src = self.template.url - # For local template paths, store as absolute so the path remains - # valid if the CWD changes between copy and update operations + # Resolve template path to a relative one when possible if src: src_path = Path(src) - if src_path.exists(): - src = str(src_path.resolve()) + # Only resolve local relative paths that exist on disk + if not src_path.is_absolute() and src_path.exists(): + src_resolved = src_path.resolve() + dst_resolved = self.subproject.local_abspath.resolve() + try: + src = os.path.relpath( + str(src_resolved), str(dst_resolved) + ) + except ValueError: + # Fallback for cross-drive paths on Windows + src = str(src_resolved) for key, value in (("_commit", commit), ("_src_path", src)): if value is not None: answers[key] = value From b363f349a43e67c93f50cbd20b5fed4dc7dafbaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20S=C3=A1nchez=20Rubio?= Date: Sat, 13 Jun 2026 17:02:57 +0200 Subject: [PATCH 3/6] refactor: use pathlib for relative template path resolution --- copier/_main.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/copier/_main.py b/copier/_main.py index 25998ab1d..d8dd3f653 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -364,20 +364,15 @@ def _answers_to_remember(self) -> Mapping[str, Any]: answers: AnyByStrDict = {} commit = self.template.commit src = self.template.url - # Resolve template path to a relative one when possible - if src: - src_path = Path(src) - # Only resolve local relative paths that exist on disk - if not src_path.is_absolute() and src_path.exists(): - src_resolved = src_path.resolve() - dst_resolved = self.subproject.local_abspath.resolve() - try: - src = os.path.relpath( - str(src_resolved), str(dst_resolved) - ) - except ValueError: - # Fallback for cross-drive paths on Windows - src = str(src_resolved) + # Resolve local relative template paths + src_path = Path(src) + if not src_path.is_absolute() and src_path.exists(): + src_resolved = src_path.resolve() + dst_resolved = self.subproject.local_abspath.resolve() + if src_resolved.is_relative_to(dst_resolved): + src = str(src_resolved.relative_to(dst_resolved)) + else: + src = str(src_resolved) for key, value in (("_commit", commit), ("_src_path", src)): if value is not None: answers[key] = value From 5cdf7fb7871809a89cdc37b1757e4f0e5bcf0a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20S=C3=A1nchez=20Rubio?= Date: Sat, 13 Jun 2026 17:04:29 +0200 Subject: [PATCH 4/6] feat: add relative template path tests --- tests/test_answersfile.py | 87 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/test_answersfile.py b/tests/test_answersfile.py index 5173d6c20..dc06e04da 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,89 @@ 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_absolute_when_outside_destination( + tmp_path: Path, +) -> None: + """Template path not under destination -> stored as absolute.""" + 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"] == str(template_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"] == ".hidden_template" + + +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) \ No newline at end of file From 1ce91a1e966cb31c699c17e440ccbca4b00a4d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20S=C3=A1nchez=20Rubio?= Date: Fri, 26 Jun 2026 12:57:27 +0200 Subject: [PATCH 5/6] fix: resolve local template paths relative to project root with fallback --- copier/_main.py | 24 +++++---- copier/_subproject.py | 12 ++++- tests/test_answersfile.py | 102 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/copier/_main.py b/copier/_main.py index d8dd3f653..d1f7701b5 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -364,15 +364,21 @@ def _answers_to_remember(self) -> Mapping[str, Any]: answers: AnyByStrDict = {} commit = self.template.commit src = self.template.url - # Resolve local relative template paths - src_path = Path(src) - if not src_path.is_absolute() and src_path.exists(): - src_resolved = src_path.resolve() - dst_resolved = self.subproject.local_abspath.resolve() - if src_resolved.is_relative_to(dst_resolved): - src = str(src_resolved.relative_to(dst_resolved)) - else: - src = str(src_resolved) + # 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 src.startswith(("http://", "https://", "git@", "git+", "gh:", "gl:", "bb:")): + 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..7196ccab3 100644 --- a/copier/_subproject.py +++ b/copier/_subproject.py @@ -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 last_url.startswith(("http://", "https://", "git@", "git+", "gh:", "gl:", "bb:")): + 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/tests/test_answersfile.py b/tests/test_answersfile.py index dc06e04da..282016901 100644 --- a/tests/test_answersfile.py +++ b/tests/test_answersfile.py @@ -553,10 +553,10 @@ def test_external_data_path_outside_destination_root_is_unsafe_on_update( -def test_relative_template_path_stored_as_absolute_when_outside_destination( +def test_relative_template_path_stored_as_relative_when_outside_destination( tmp_path: Path, ) -> None: - """Template path not under destination -> stored as absolute.""" + """Template path not under destination -> stored as relative.""" root = tmp_path template_dir = root / "template" project_dir = root / "project" @@ -579,7 +579,7 @@ def test_relative_template_path_stored_as_absolute_when_outside_destination( os.chdir(old_cwd) answers = load_answersfile_data(project_dir) - assert answers["_src_path"] == str(template_dir.resolve()) + 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( @@ -610,7 +610,7 @@ def test_relative_template_path_stored_as_relative_when_inside_destination( os.chdir(old_cwd) answers = load_answersfile_data(project_dir) - assert answers["_src_path"] == ".hidden_template" + assert answers["_src_path"] == os.path.relpath(str(hidden_template_dir.resolve()), str(project_dir.resolve())) def test_absolute_template_path_stored_as_is( @@ -635,4 +635,96 @@ def test_absolute_template_path_stored_as_is( ) answers = load_answersfile_data(project_dir) - assert answers["_src_path"] == str(template_dir) \ No newline at end of file + 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 From 1ec02056f2a9293bfd9e9afe59807b6ec3839b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20S=C3=A1nchez=20Rubio?= Date: Thu, 30 Jul 2026 15:03:16 +0200 Subject: [PATCH 6/6] refactor: extract REMOTE_URL_PREFIXES and is_remote_url() to _vcs.py --- copier/_main.py | 4 ++-- copier/_subproject.py | 4 ++-- copier/_vcs.py | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/copier/_main.py b/copier/_main.py index d1f7701b5..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, @@ -372,7 +372,7 @@ def _answers_to_remember(self) -> Mapping[str, Any]: 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 src.startswith(("http://", "https://", "git@", "git+", "gh:", "gl:", "bb:")): + if was_relative and src and not is_remote_url(src): try: src_resolved = Path(src).resolve() dst_resolved = self.subproject.local_abspath.resolve() diff --git a/copier/_subproject.py b/copier/_subproject.py index 7196ccab3..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 @@ -77,7 +77,7 @@ def template(self) -> Template | None: last_ref = self.last_answers.get("_commit") if last_url: url = last_url - if not last_url.startswith(("http://", "https://", "git@", "git+", "gh:", "gl:", "bb:")): + if not is_remote_url(last_url): try: path = Path(last_url) if not path.is_absolute(): 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."""