Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions copier/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 src.startswith(("http://", "https://", "git@", "git+", "gh:", "gl:", "bb:")):
Comment thread
Oscar-SR marked this conversation as resolved.
Outdated
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
Expand Down
12 changes: 11 additions & 1 deletion copier/_subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions tests/test_answersfile.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)