From 7acf2b3acfbd831dd7b5ab35a5985ff8854eb346 Mon Sep 17 00:00:00 2001 From: Emi Katagiri-Simpson Date: Sat, 22 Mar 2025 23:15:45 -0400 Subject: [PATCH] Dereference symlinks before hardlinking (see #5676) --- beets/util/__init__.py | 4 +++- docs/changelog.rst | 4 ++++ test/test_files.py | 18 +++++++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index b882ed626..6f51bbd52 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -605,7 +605,9 @@ def hardlink(path: bytes, dest: bytes, replace: bool = False): if os.path.exists(syspath(dest)) and not replace: raise FilesystemError("file exists", "rename", (path, dest)) try: - os.link(syspath(path), syspath(dest)) + # This step dereferences any symlinks and converts to an absolute path + resolved_origin = Path(syspath(path)).resolve() + os.link(resolved_origin, syspath(dest)) except NotImplementedError: raise FilesystemError( "OS does not support hard links." "link", diff --git a/docs/changelog.rst b/docs/changelog.rst index 88d87e32f..27fcbc24f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,6 +29,10 @@ Bug fixes: * :doc:`plugins/fetchart`: Fix fetchart bug where a tempfile could not be deleted due to never being properly closed. :bug:`5521` +* When hardlinking from a symlink (e.g. importing a symlink with hardlinking + enabled), dereference the symlink then hardlink, rather than creating a new + (potentially broken) symlink + :bug:`5676` * :doc:`plugins/lyrics`: LRCLib will fallback to plain lyrics if synced lyrics are not found and `synced` flag is set to `yes`. * Synchronise files included in the source distribution with what we used to diff --git a/test/test_files.py b/test/test_files.py index 72b1610c0..c99f8f02b 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -35,7 +35,8 @@ class MoveTest(BeetsTestCase): super().setUp() # make a temporary file - self.path = join(self.temp_dir, b"temp.mp3") + self.temp_music_file_name = b"temp.mp3" + self.path = join(self.temp_dir, self.temp_music_file_name) shutil.copy( syspath(join(_common.RSRC, b"full.mp3")), syspath(self.path), @@ -199,6 +200,21 @@ class MoveTest(BeetsTestCase): self.i.move(operation=MoveOperation.HARDLINK) assert self.i.path == util.normpath(self.dest) + @unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks") + def test_hardlink_from_symlink(self): + link_path = join(self.temp_dir, b"temp_link.mp3") + link_source = join(b"./", self.temp_music_file_name) + os.symlink(syspath(link_source), syspath(link_path)) + self.i.path = link_path + self.i.move(operation=MoveOperation.HARDLINK) + + s1 = os.stat(syspath(self.path)) + s2 = os.stat(syspath(self.dest)) + assert (s1[stat.ST_INO], s1[stat.ST_DEV]) == ( + s2[stat.ST_INO], + s2[stat.ST_DEV], + ) + class HelperTest(BeetsTestCase): def test_ancestry_works_on_file(self):