Skip to content

Commit 1ce91a1

Browse files
committed
fix: resolve local template paths relative to project root with fallback
1 parent 5cdf7fb commit 1ce91a1

3 files changed

Lines changed: 123 additions & 15 deletions

File tree

copier/_main.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -364,15 +364,21 @@ def _answers_to_remember(self) -> Mapping[str, Any]:
364364
answers: AnyByStrDict = {}
365365
commit = self.template.commit
366366
src = self.template.url
367-
# Resolve local relative template paths
368-
src_path = Path(src)
369-
if not src_path.is_absolute() and src_path.exists():
370-
src_resolved = src_path.resolve()
371-
dst_resolved = self.subproject.local_abspath.resolve()
372-
if src_resolved.is_relative_to(dst_resolved):
373-
src = str(src_resolved.relative_to(dst_resolved))
374-
else:
375-
src = str(src_resolved)
367+
# Check if the original path was relative
368+
src_path = Path(self.src_path) if self.src_path else None
369+
if src_path is None:
370+
last_src = self.subproject.last_answers.get("_src_path")
371+
src_path = Path(last_src) if last_src else None
372+
was_relative = src_path is not None and not src_path.is_absolute()
373+
374+
# If original was relative and it is not a remote Git repo, save as relative to the subproject root
375+
if was_relative and src and not src.startswith(("http://", "https://", "git@", "git+", "gh:", "gl:", "bb:")):
376+
try:
377+
src_resolved = Path(src).resolve()
378+
dst_resolved = self.subproject.local_abspath.resolve()
379+
src = os.path.relpath(str(src_resolved), str(dst_resolved))
380+
except (ValueError, OSError):
381+
pass
376382
for key, value in (("_commit", commit), ("_src_path", src)):
377383
if value is not None:
378384
answers[key] = value

copier/_subproject.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,17 @@ def template(self) -> Template | None:
7676
last_url = self.last_answers.get("_src_path")
7777
last_ref = self.last_answers.get("_commit")
7878
if last_url:
79-
result = Template(url=last_url, ref=last_ref)
79+
url = last_url
80+
if not last_url.startswith(("http://", "https://", "git@", "git+", "gh:", "gl:", "bb:")):
81+
try:
82+
path = Path(last_url)
83+
if not path.is_absolute():
84+
resolved_path = (self.local_abspath / path).resolve()
85+
if resolved_path.is_dir():
86+
url = str(resolved_path)
87+
except OSError:
88+
pass
89+
result = Template(url=url, ref=last_ref)
8090
self._cleanup_hooks.append(result._cleanup)
8191
return result
8292
return None

tests/test_answersfile.py

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -553,10 +553,10 @@ def test_external_data_path_outside_destination_root_is_unsafe_on_update(
553553

554554

555555

556-
def test_relative_template_path_stored_as_absolute_when_outside_destination(
556+
def test_relative_template_path_stored_as_relative_when_outside_destination(
557557
tmp_path: Path,
558558
) -> None:
559-
"""Template path not under destination -> stored as absolute."""
559+
"""Template path not under destination -> stored as relative."""
560560
root = tmp_path
561561
template_dir = root / "template"
562562
project_dir = root / "project"
@@ -579,7 +579,7 @@ def test_relative_template_path_stored_as_absolute_when_outside_destination(
579579
os.chdir(old_cwd)
580580

581581
answers = load_answersfile_data(project_dir)
582-
assert answers["_src_path"] == str(template_dir.resolve())
582+
assert answers["_src_path"] == os.path.relpath(str(template_dir.resolve()), str(project_dir.resolve()))
583583

584584

585585
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(
610610
os.chdir(old_cwd)
611611

612612
answers = load_answersfile_data(project_dir)
613-
assert answers["_src_path"] == ".hidden_template"
613+
assert answers["_src_path"] == os.path.relpath(str(hidden_template_dir.resolve()), str(project_dir.resolve()))
614614

615615

616616
def test_absolute_template_path_stored_as_is(
@@ -635,4 +635,96 @@ def test_absolute_template_path_stored_as_is(
635635
)
636636

637637
answers = load_answersfile_data(project_dir)
638-
assert answers["_src_path"] == str(template_dir)
638+
assert answers["_src_path"] == str(template_dir)
639+
640+
641+
def test_relative_template_path_resolved_relative_to_project_root(
642+
tmp_path: Path,
643+
) -> None:
644+
"""Template path resolved relative to project root on update, even if CWD is different."""
645+
root = tmp_path
646+
template_dir = root / "template"
647+
project_dir = root / "project"
648+
649+
build_file_tree(
650+
{
651+
(template_dir / "{{ _copier_conf.answers_file }}.jinja"): (
652+
"{{ _copier_answers|to_nice_yaml }}"
653+
),
654+
}
655+
)
656+
git_save(template_dir, tag="v1")
657+
658+
project_dir.mkdir(exist_ok=True)
659+
660+
# Initial copy
661+
old_cwd = Path.cwd()
662+
try:
663+
os.chdir(root)
664+
copier.run_copy("./template", "./project", defaults=True, overwrite=True)
665+
finally:
666+
os.chdir(old_cwd)
667+
668+
answers = load_answersfile_data(project_dir)
669+
# Verify it was saved as relative
670+
assert answers["_src_path"] == os.path.relpath(str(template_dir.resolve()), str(project_dir.resolve()))
671+
672+
# Initialize project as git repository and commit answers file
673+
git_save(project_dir)
674+
675+
# Run update from a completely different directory (so relative to CWD would fail)
676+
other_dir = root / "other"
677+
other_dir.mkdir(exist_ok=True)
678+
try:
679+
os.chdir(other_dir)
680+
# update should succeed because template path is resolved relative to project root
681+
copier.run_update(str(project_dir), defaults=True, overwrite=True)
682+
finally:
683+
os.chdir(old_cwd)
684+
685+
686+
def test_relative_template_path_fallback_resolved_relative_to_cwd(
687+
tmp_path: Path,
688+
) -> None:
689+
"""Template path fallback resolved relative to CWD if not found relative to project root."""
690+
root = tmp_path
691+
template_dir = root / "template"
692+
project_dir = root / "project"
693+
694+
build_file_tree(
695+
{
696+
(template_dir / "{{ _copier_conf.answers_file }}.jinja"): (
697+
"{{ _copier_answers|to_nice_yaml }}"
698+
),
699+
}
700+
)
701+
git_save(template_dir, tag="v1")
702+
703+
project_dir.mkdir(exist_ok=True)
704+
705+
# Initial copy
706+
old_cwd = Path.cwd()
707+
try:
708+
os.chdir(root)
709+
copier.run_copy("./template", "./project", defaults=True, overwrite=True)
710+
finally:
711+
os.chdir(old_cwd)
712+
713+
# Manually modify _src_path to be "template" (which is relative to CWD 'root',
714+
# but "project/template" does not exist).
715+
answers_file = project_dir / ".copier-answers.yml"
716+
content = answers_file.read_text()
717+
# Replace relative path (e.g. "../template") with "template"
718+
rel_path = os.path.relpath(str(template_dir.resolve()), str(project_dir.resolve()))
719+
assert rel_path in content
720+
answers_file.write_text(content.replace(rel_path, "template"))
721+
722+
# Initialize project as git repository and commit answers file
723+
git_save(project_dir)
724+
725+
try:
726+
os.chdir(root)
727+
# update should succeed because fallback resolves "template" relative to CWD (root)
728+
copier.run_update(str(project_dir), defaults=True, overwrite=True)
729+
finally:
730+
os.chdir(old_cwd)

0 commit comments

Comments
 (0)