Skip to content

Commit

Permalink
fix: Crash when update replaces file with symlink (or vice versa)
Browse files Browse the repository at this point in the history
Previously if an update would try to replace a file with a symlink
copier would crash. If an update tried to replace a symlink with a file
it would just overwrite the symlink target. Now both cases work as
expected.
  • Loading branch information
freundTech committed Nov 15, 2023
1 parent d3216ad commit ed09cfc
Show file tree
Hide file tree
Showing 2 changed files with 70 additions and 2 deletions.
11 changes: 9 additions & 2 deletions copier/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,9 +373,10 @@ def _render_allowed(
dst_abspath = Path(self.subproject.local_abspath, dst_relpath)
if dst_relpath != Path(".") and self.match_exclude(dst_relpath):
return False
previous_is_symlink = dst_abspath.is_symlink()
try:
previous_content: Union[bytes, Path]
if is_symlink:
if previous_is_symlink:
previous_content = readlink(dst_abspath)
else:
previous_content = dst_abspath.read_bytes()
Expand All @@ -394,7 +395,9 @@ def _render_allowed(
raise
except IsADirectoryError:
assert is_dir
if is_dir or previous_content == expected_contents:
if is_dir or (
previous_content == expected_contents and previous_is_symlink == is_symlink
):
printf(
"identical",
dst_relpath,
Expand Down Expand Up @@ -573,6 +576,10 @@ def _render_file(self, src_abspath: Path) -> None:
return
if not self.pretend:
dst_abspath.parent.mkdir(parents=True, exist_ok=True)
if dst_abspath.is_symlink():
# Writing to a symlink just writes to its target, so if we want to
# replace a symlink with a file we have to unlink it first
dst_abspath.unlink()
dst_abspath.write_bytes(new_content)
dst_abspath.chmod(src_mode)

Expand Down
61 changes: 61 additions & 0 deletions tests/test_symlinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,67 @@ def test_update_symlink(tmp_path_factory: pytest.TempPathFactory) -> None:
assert readlink(dst / "symlink.txt") == Path("bbbb.txt")


def test_update_file_to_symlink(tmp_path_factory: pytest.TempPathFactory) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))

build_file_tree(
{
src
/ ".copier-answers.yml.jinja": """\
# Changes here will be overwritten by Copier
{{ _copier_answers|to_nice_yaml }}
""",
src
/ "copier.yml": """\
_preserve_symlinks: true
""",
src
/ "aaaa.txt": """
Lorem ipsum
""",
src
/ "bbbb.txt": """
dolor sit amet
""",
src / "cccc.txt": Path("./aaaa.txt"),
}
)

with local.cwd(src):
git("init")
git("add", "-A")
git("commit", "-m", "first commit on src")

run_copy(str(src), dst, defaults=True, overwrite=True)

with local.cwd(src):
# test updating a symlink
os.remove("bbbb.txt")
os.symlink("aaaa.txt", "bbbb.txt")
os.remove("cccc.txt")
with open("cccc.txt", "w+") as f:
f.write("Lorem ipsum")

# dst must be vcs-tracked to use run_update
with local.cwd(dst):
git("init")
git("add", "-A")
git("commit", "-m", "first commit on dst")

# make sure changes have not yet propagated
assert not (dst / "bbbb.txt").is_symlink()
assert (dst / "cccc.txt").is_symlink()

with pytest.warns(DirtyLocalWarning):
run_update(dst, defaults=True, overwrite=True)

# make sure changes propagate after update
assert (dst / "bbbb.txt").is_symlink()
assert readlink(dst / "bbbb.txt") == Path("aaaa.txt")
assert not (dst / "cccc.txt").is_symlink()
assert (dst / "cccc.txt").read_text() == "Lorem ipsum"


def test_exclude_symlink(tmp_path_factory: pytest.TempPathFactory) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
# Prepare repo bundle
Expand Down

0 comments on commit ed09cfc

Please sign in to comment.