Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
17 changes: 16 additions & 1 deletion copier/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down 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 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
Expand Down
14 changes: 12 additions & 2 deletions copier/_subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down 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 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
Expand Down
6 changes: 6 additions & 0 deletions copier/_vcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
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)