From 7acf2b3acfbd831dd7b5ab35a5985ff8854eb346 Mon Sep 17 00:00:00 2001 From: Emi Katagiri-Simpson Date: Sat, 22 Mar 2025 23:15:45 -0400 Subject: [PATCH 1/2] 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): From b405d2fded8a7bb0591c0cd6ff7a9fd1c8c9bc18 Mon Sep 17 00:00:00 2001 From: Emi Katagiri-Simpson Date: Fri, 7 Nov 2025 15:05:56 -0500 Subject: [PATCH 2/2] Migrate `os` calls to `pathlib` calls in hardlink util function See discussion here: https://github.com/beetbox/beets/pull/5684#discussion_r2502432781 --- beets/util/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index b053e9c73..c95c2e523 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -578,12 +578,14 @@ def hardlink(path: bytes, dest: bytes, replace: bool = False): if samefile(path, dest): return - if os.path.exists(syspath(dest)) and not replace: + # Dereference symlinks, expand "~", and convert relative paths to absolute + origin_path = Path(os.fsdecode(path)).expanduser().resolve() + dest_path = Path(os.fsdecode(dest)).expanduser().resolve() + + if dest_path.exists() and not replace: raise FilesystemError("file exists", "rename", (path, dest)) try: - # This step dereferences any symlinks and converts to an absolute path - resolved_origin = Path(syspath(path)).resolve() - os.link(resolved_origin, syspath(dest)) + dest_path.hardlink_to(origin_path) except NotImplementedError: raise FilesystemError( "OS does not support hard links.link",