diff --git a/README.md b/README.md index 7cdfc2ce6..787b044b8 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,8 @@ Copy config files from the Mackup folder to your home folder. Move your local config files into the Mackup folder, and link them to their original place. - -$${\color{red}warning}$$ _the `link` strategy [doesn't work correctly on macOS](#link-mode)_ +On macOS, Mackup copies any original files under `~/Library` instead of +symlinking them. `mackup link` @@ -131,11 +131,12 @@ It is covered by the 2 commands: ### Link mode > [!WARNING] -> If you are using Mackup on a current version of macOS, link mode will BREAK - YOUR PREFERENCES. macOS Sonoma (macOS 14) and later don't support symlinked - preferences, see [issue #2035](https://github.com/lra/mackup/issues/2035) for - additional information. [PR #2085]() - added copy mode, which should be used instead. +> If you are using Mackup on a current version of macOS, symlinking files under + `~/Library` will BREAK YOUR PREFERENCES. macOS Sonoma (macOS 14) and later + don't support symlinked preferences, see + [issue #2035](https://github.com/lra/mackup/issues/2035) for additional + information. Mackup falls back to copy mode for `~/Library` paths on macOS + and keeps link mode for files elsewhere in your home directory. Link mode is used to move your config files into the Mackup folder, and link them back to their original place. @@ -166,6 +167,9 @@ If you have Dropbox, these things happen when you launch `mackup link install`: Now your `git` config is always backed up and up to date on all your workstations. +On macOS, files under `~/Library` are copied into the Mackup folder and left as +regular files in place instead of being symlinked. + #### `mackup link` When you launch `mackup link`, here's what it's really doing: @@ -175,6 +179,8 @@ When you launch `mackup link`, here's what it's really doing: That's it, you got your `git` config setup on your new workstation. `mackup` does the same for any supported application. +On macOS, `~/Library` paths are restored by copying from the Mackup folder +instead of creating symlinks. #### `mackup link uninstall` diff --git a/src/mackup/application.py b/src/mackup/application.py index 5369b6445..f7ac34c20 100644 --- a/src/mackup/application.py +++ b/src/mackup/application.py @@ -6,8 +6,9 @@ """ import os +import platform -from . import utils +from . import constants, utils from .mackup import Mackup @@ -47,6 +48,260 @@ def get_filepaths(self, filename: str) -> tuple[str, str]: os.path.join(self.mackup.mackup_folder, filename), ) + def _should_use_copy_mode_in_link_workflow(self, filename: str) -> bool: + """Return whether link commands should fall back to copy mode.""" + if platform.system() != constants.PLATFORM_DARWIN: + return False + + (home_filepath, _) = self.get_filepaths(filename) + library_path = os.path.join(os.environ["HOME"], "Library") + normalized_home_filepath = os.path.normpath(home_filepath) + normalized_library_path = os.path.normpath(library_path) + + return normalized_home_filepath == normalized_library_path or ( + normalized_home_filepath.startswith(normalized_library_path + os.sep) + ) + + def _copy_file_to_mackup_folder(self, filename: str) -> None: + """Back up a single application config file to the Mackup folder.""" + (home_filepath, mackup_filepath) = self.get_filepaths(filename) + + if os.path.isfile(home_filepath) or os.path.isdir(home_filepath): + if ( + os.path.islink(home_filepath) + and os.path.exists(mackup_filepath) + and os.path.samefile(home_filepath, mackup_filepath) + ): + if self.verbose: + print( + f"Skipping {home_filepath}\n" + f" already linked to\n {mackup_filepath}", + ) + return + + if self.verbose: + print( + f"Backing up\n {home_filepath}\n to\n {mackup_filepath} ...", + ) + else: + print(f"Backing up {filename} ...") + + if self.dry_run: + return + + if os.path.lexists(mackup_filepath): + file_type: str + if os.path.isfile(mackup_filepath): + file_type = "file" + elif os.path.isdir(mackup_filepath): + file_type = "folder" + elif os.path.islink(mackup_filepath): + file_type = "link" + else: + raise ValueError(f"Unsupported file: {mackup_filepath}") + + if utils.confirm( + f"A {file_type} named {mackup_filepath} already exists in the" + " Mackup folder.\nAre you sure that you want to" + " replace it? (use --force to skip this prompt)", + ): + utils.delete(mackup_filepath) + else: + return + + try: + utils.copy(home_filepath, mackup_filepath) + except PermissionError as e: + print( + f"Error: Unable to copy file from {home_filepath} to " + f"{mackup_filepath} due to permission issue: {e}", + ) + + def _copy_file_from_mackup_folder(self, filename: str) -> None: + """Recover a single application config file from the Mackup folder.""" + (home_filepath, mackup_filepath) = self.get_filepaths(filename) + + if os.path.isfile(mackup_filepath) or os.path.isdir(mackup_filepath): + if self.verbose: + print( + f"Recovering\n {mackup_filepath}\n to\n {home_filepath} ...", + ) + else: + print(f"Recovering {filename} ...") + + if self.dry_run: + return + + if os.path.lexists(home_filepath): + if os.path.isfile(home_filepath): + file_type = "file" + elif os.path.isdir(home_filepath): + file_type = "folder" + elif os.path.islink(home_filepath): + file_type = "link" + else: + raise ValueError(f"Unsupported file: {home_filepath}") + + if utils.confirm( + f"A {file_type} named {home_filepath} already exists in your" + " home folder.\nAre you sure that you want to" + " replace it?", + ): + utils.delete(home_filepath) + else: + return + + try: + utils.copy(mackup_filepath, home_filepath) + except PermissionError as e: + print( + f"Error: Unable to copy file from {mackup_filepath} to " + f"{home_filepath} due to permission issue: {e}", + ) + + def _link_install_file(self, filename: str) -> None: + """Create a single application config file link.""" + (home_filepath, mackup_filepath) = self.get_filepaths(filename) + + if (os.path.isfile(home_filepath) or os.path.isdir(home_filepath)) and not ( + os.path.islink(home_filepath) + and (os.path.isfile(mackup_filepath) or os.path.isdir(mackup_filepath)) + and os.path.samefile(home_filepath, mackup_filepath) + ): + if self.verbose: + print( + f"Backing up\n {home_filepath}\n to\n {mackup_filepath} ...", + ) + else: + print(f"Linking {filename} ...") + + if self.dry_run: + return + + if os.path.exists(mackup_filepath): + if os.path.isfile(mackup_filepath): + file_type = "file" + elif os.path.isdir(mackup_filepath): + file_type = "folder" + elif os.path.islink(mackup_filepath): + file_type = "link" + else: + raise ValueError(f"Unsupported file: {mackup_filepath}") + + if utils.confirm( + f"A {file_type} named {mackup_filepath} already exists in the" + " backup.\nAre you sure that you want to" + " replace it?", + ): + utils.delete(mackup_filepath) + utils.copy(home_filepath, mackup_filepath) + utils.delete(home_filepath) + utils.link(mackup_filepath, home_filepath) + else: + utils.copy(home_filepath, mackup_filepath) + utils.delete(home_filepath) + utils.link(mackup_filepath, home_filepath) + elif self.verbose: + if os.path.exists(home_filepath): + print( + f"Doing nothing\n {home_filepath}\n " + f"is already backed up to\n {mackup_filepath}", + ) + elif os.path.islink(home_filepath): + print( + f"Doing nothing\n {home_filepath}\n " + "is a broken link, you might want to fix it.", + ) + else: + print(f"Doing nothing\n {home_filepath}\n does not exist") + + def _link_file(self, filename: str) -> None: + """Link a single application config file from Mackup to home.""" + (home_filepath, mackup_filepath) = self.get_filepaths(filename) + + file_or_dir_exists: bool = os.path.isfile(mackup_filepath) or os.path.isdir( + mackup_filepath, + ) + pointing_to_mackup: bool = ( + os.path.islink(home_filepath) + and os.path.exists(mackup_filepath) + and os.path.samefile(mackup_filepath, home_filepath) + ) + supported: bool = utils.can_file_be_synced_on_current_platform(filename) + + if file_or_dir_exists and not pointing_to_mackup and supported: + if self.verbose: + print( + f"Restoring\n linking {home_filepath}\n" + f" to {mackup_filepath} ...", + ) + else: + print(f"Restoring {filename} ...") + + if self.dry_run: + return + + if os.path.exists(home_filepath): + if os.path.isfile(home_filepath): + file_type = "file" + elif os.path.isdir(home_filepath): + file_type = "folder" + elif os.path.islink(home_filepath): + file_type = "link" + else: + raise ValueError(f"Unsupported file: {home_filepath}") + + if utils.confirm( + f"You already have a {file_type} at {home_filepath}.\n" + "Do you want to replace it with your backup?", + ): + utils.delete(home_filepath) + utils.link(mackup_filepath, home_filepath) + else: + utils.link(mackup_filepath, home_filepath) + elif self.verbose: + if os.path.exists(home_filepath): + print( + f"Doing nothing\n {mackup_filepath}\n" + f" already linked by\n {home_filepath}", + ) + elif os.path.islink(home_filepath): + print( + f"Doing nothing\n {home_filepath}\n " + "is a broken link, you might want to fix it.", + ) + else: + print(f"Doing nothing\n {mackup_filepath}\n does not exist") + + def _link_uninstall_file(self, filename: str) -> None: + """Revert a single symlinked application config file back to a copy.""" + (home_filepath, mackup_filepath) = self.get_filepaths(filename) + + if os.path.isfile(mackup_filepath) or os.path.isdir(mackup_filepath): + if os.path.exists(home_filepath): + if not os.path.islink(home_filepath) or not os.path.samefile( + home_filepath, mackup_filepath, + ): + print( + f'Warning: the file in your home "{home_filepath}" ' + f"does not point to the original file in Mackup " + f"{mackup_filepath}, skipping...", + ) + return + + if self.verbose: + print(f"Reverting {mackup_filepath}\n at {home_filepath} ...") + else: + print(f"Reverting {filename} ...") + + if self.dry_run: + return + + utils.delete(home_filepath) + utils.copy(mackup_filepath, home_filepath) + elif self.verbose: + print(f"Doing nothing, {mackup_filepath} does not exist") + def copy_files_to_mackup_folder(self) -> None: """ Backup the application config files to the Mackup folder. @@ -63,65 +318,7 @@ def copy_files_to_mackup_folder(self) -> None: cp home/file mackup/file """ for filename in self.files: - (home_filepath, mackup_filepath) = self.get_filepaths(filename) - - # If config_file exists and is a real file/folder - if (os.path.isfile(home_filepath) or os.path.isdir(home_filepath)): - # Check if home file is a symlink pointing to mackup file - # (already backed up via link install) - if ( - os.path.islink(home_filepath) - and os.path.exists(mackup_filepath) - and os.path.samefile(home_filepath, mackup_filepath) - ): - if self.verbose: - print( - f"Skipping {home_filepath}\n" - f" already linked to\n {mackup_filepath}", - ) - continue - - if self.verbose: - print( - f"Backing up\n {home_filepath}\n to\n {mackup_filepath} ...", - ) - else: - print(f"Backing up {filename} ...") - - if self.dry_run: - continue - - # If exists mackup/file - if os.path.lexists(mackup_filepath): - # Name it right - file_type: str - if os.path.isfile(mackup_filepath): - file_type = "file" - elif os.path.isdir(mackup_filepath): - file_type = "folder" - elif os.path.islink(mackup_filepath): - file_type = "link" - else: - raise ValueError(f"Unsupported file: {mackup_filepath}") - # Ask the user if he really wants to replace it - if utils.confirm( - f"A {file_type} named {mackup_filepath} already exists in the" - " Mackup folder.\nAre you sure that you want to" - " replace it? (use --force to skip this prompt)", - ): - # If confirmed, delete the file in Mackup - utils.delete(mackup_filepath) - else: - continue - - # Copy the file - try: - utils.copy(home_filepath, mackup_filepath) - except PermissionError as e: - print( - f"Error: Unable to copy file from {home_filepath} to " - f"{mackup_filepath} due to permission issue: {e}", - ) + self._copy_file_to_mackup_folder(filename) def copy_files_from_mackup_folder(self) -> None: """ @@ -137,50 +334,7 @@ def copy_files_from_mackup_folder(self) -> None: cp mackup/file home/file """ for filename in self.files: - (home_filepath, mackup_filepath) = self.get_filepaths(filename) - - # If config_file exists in mackup and is a real file/folder - if (os.path.isfile(mackup_filepath) or os.path.isdir(mackup_filepath)): - if self.verbose: - print( - f"Recovering\n {mackup_filepath}\n to\n {home_filepath} ...", - ) - else: - print(f"Recovering {filename} ...") - - if self.dry_run: - continue - - # If exists home/file - if os.path.lexists(home_filepath): - # Name it right - if os.path.isfile(home_filepath): - file_type = "file" - elif os.path.isdir(home_filepath): - file_type = "folder" - elif os.path.islink(home_filepath): - file_type = "link" - else: - raise ValueError(f"Unsupported file: {home_filepath}") - # Ask the user if he really wants to replace it - if utils.confirm( - f"A {file_type} named {home_filepath} already exists in your" - " home folder.\nAre you sure that you want to" - " replace it?", - ): - # If confirmed, delete the existing home file - utils.delete(home_filepath) - else: - continue - - # Copy the file - try: - utils.copy(mackup_filepath, home_filepath) - except PermissionError as e: - print( - f"Error: Unable to copy file from {mackup_filepath} to " - f"{home_filepath} due to permission issue: {e}", - ) + self._copy_file_from_mackup_folder(filename) def link_install(self) -> None: """ @@ -199,72 +353,11 @@ def link_install(self) -> None: mv home/file mackup/file link mackup/file home/file """ - # For each file used by the application for filename in self.files: - (home_filepath, mackup_filepath) = self.get_filepaths(filename) - - # If the file exists and is not already a link pointing to Mackup - if (os.path.isfile(home_filepath) or os.path.isdir(home_filepath)) and not ( - os.path.islink(home_filepath) - and (os.path.isfile(mackup_filepath) or os.path.isdir(mackup_filepath)) - and os.path.samefile(home_filepath, mackup_filepath) - ): - if self.verbose: - print( - f"Backing up\n {home_filepath}\n to\n {mackup_filepath} ...", - ) - else: - print(f"Linking {filename} ...") - - if self.dry_run: - continue - - # Check if we already have a backup - if os.path.exists(mackup_filepath): - # Name it right - if os.path.isfile(mackup_filepath): - file_type = "file" - elif os.path.isdir(mackup_filepath): - file_type = "folder" - elif os.path.islink(mackup_filepath): - file_type = "link" - else: - raise ValueError(f"Unsupported file: {mackup_filepath}") - - # Ask the user if he really wants to replace it - if utils.confirm( - f"A {file_type} named {mackup_filepath} already exists in the" - " backup.\nAre you sure that you want to" - " replace it?", - ): - # Delete the file in Mackup - utils.delete(mackup_filepath) - # Copy the file - utils.copy(home_filepath, mackup_filepath) - # Delete the file in the home - utils.delete(home_filepath) - # Link the backuped file to its original place - utils.link(mackup_filepath, home_filepath) - else: - # Copy the file - utils.copy(home_filepath, mackup_filepath) - # Delete the file in the home - utils.delete(home_filepath) - # Link the backuped file to its original place - utils.link(mackup_filepath, home_filepath) - elif self.verbose: - if os.path.exists(home_filepath): - print( - f"Doing nothing\n {home_filepath}\n " - f"is already backed up to\n {mackup_filepath}", - ) - elif os.path.islink(home_filepath): - print( - f"Doing nothing\n {home_filepath}\n " - "is a broken link, you might want to fix it.", - ) - else: - print(f"Doing nothing\n {home_filepath}\n does not exist") + if self._should_use_copy_mode_in_link_workflow(filename): + self._copy_file_to_mackup_folder(filename) + else: + self._link_install_file(filename) def link(self) -> None: """ @@ -280,70 +373,11 @@ def link(self) -> None: else link mackup/file home/file """ - # For each file used by the application for filename in self.files: - (home_filepath, mackup_filepath) = self.get_filepaths(filename) - - # If the file exists and is not already pointing to the mackup file - # and the folder makes sense on the current platform (Don't sync - # any subfolder of ~/Library on GNU/Linux) - file_or_dir_exists: bool = os.path.isfile(mackup_filepath) or os.path.isdir( - mackup_filepath, - ) - pointing_to_mackup: bool = ( - os.path.islink(home_filepath) - and os.path.exists(mackup_filepath) - and os.path.samefile(mackup_filepath, home_filepath) - ) - supported: bool = utils.can_file_be_synced_on_current_platform(filename) - - if file_or_dir_exists and not pointing_to_mackup and supported: - if self.verbose: - print( - f"Restoring\n linking {home_filepath}\n" - f" to {mackup_filepath} ...", - ) - else: - print(f"Restoring {filename} ...") - - if self.dry_run: - continue - - # Check if there is already a file in the home folder - if os.path.exists(home_filepath): - # Name it right - if os.path.isfile(home_filepath): - file_type = "file" - elif os.path.isdir(home_filepath): - file_type = "folder" - elif os.path.islink(home_filepath): - file_type = "link" - else: - raise ValueError(f"Unsupported file: {home_filepath}") - - if utils.confirm( - f"You already have a {file_type} at {home_filepath}.\n" - "Do you want to replace it with your backup?", - ): - utils.delete(home_filepath) - utils.link(mackup_filepath, home_filepath) - else: - utils.link(mackup_filepath, home_filepath) - elif self.verbose: - if os.path.exists(home_filepath): - print( - f"Doing nothing\n {mackup_filepath}\n" - f" already linked by\n {home_filepath}", - ) - elif os.path.islink(home_filepath): - print( - f"Doing nothing\n {home_filepath}\n " - "is a broken link, you might want to fix it.", - ) - else: - print( - f"Doing nothing\n {mackup_filepath}\n does not exist", - ) + if self._should_use_copy_mode_in_link_workflow(filename): + self._copy_file_from_mackup_folder(filename) + else: + self._link_file(filename) def link_uninstall(self) -> None: """ @@ -356,40 +390,8 @@ def link_uninstall(self) -> None: delete home/file copy mackup/file home/file """ - # For each file used by the application for filename in self.files: - (home_filepath, mackup_filepath) = self.get_filepaths(filename) - - # If the mackup file exists - if os.path.isfile(mackup_filepath) or os.path.isdir(mackup_filepath): - # Check if there is a corresponding file in the home folder - if os.path.exists(home_filepath): - # If the home file is not a link or does not point to the - # mackup file, display a warning and skip it. - if not os.path.islink(home_filepath) or not os.path.samefile( - home_filepath, mackup_filepath, - ): - print( - f'Warning: the file in your home "{home_filepath}" ' - f"does not point to the original file in Mackup " - f"{mackup_filepath}, skipping...", - ) - continue - if self.verbose: - print( - f"Reverting {mackup_filepath}\n at {home_filepath} ...", - ) - else: - print(f"Reverting {filename} ...") - - if self.dry_run: - continue - - # If there is, delete it as we are gonna copy the Dropbox - # one there - utils.delete(home_filepath) - - # Copy the Dropbox file to the home folder - utils.copy(mackup_filepath, home_filepath) - elif self.verbose: - print(f"Doing nothing, {mackup_filepath} does not exist") + if self._should_use_copy_mode_in_link_workflow(filename): + self._copy_file_from_mackup_folder(filename) + else: + self._link_uninstall_file(filename) diff --git a/tests/test_application.py b/tests/test_application.py index a2373d845..4245d0b2d 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -651,6 +651,108 @@ def test_copy_files_to_mackup_folder_backs_up_symlink_to_different_location(self output = captured_output.getvalue() assert "Backing up" in output + def test_link_install_uses_copy_for_library_paths_on_macos(self): + """macOS should copy ~/Library files during link install.""" + test_file = "Library/Preferences/com.test.app.plist" + app_profile = ApplicationProfile( + mackup=self.mock_mackup, + files={test_file}, + dry_run=False, + verbose=False, + ) + + home_filepath = os.path.join(self.temp_home, test_file) + mackup_filepath = os.path.join(self.mock_mackup.mackup_folder, test_file) + os.makedirs(os.path.dirname(home_filepath), exist_ok=True) + + with open(home_filepath, "w") as f: + f.write("plist content") + + with patch("mackup.application.platform.system", return_value="Darwin"), \ + patch("mackup.application.utils.link") as mock_link, \ + patch("mackup.application.utils.delete") as mock_delete: + app_profile.link_install() + + mock_link.assert_not_called() + mock_delete.assert_not_called() + assert os.path.exists(home_filepath) + assert not os.path.islink(home_filepath) + assert os.path.exists(mackup_filepath) + with open(mackup_filepath) as f: + assert f.read() == "plist content" + + def test_link_install_keeps_link_strategy_for_non_library_paths_on_macos(self): + """macOS should still link files outside ~/Library during link install.""" + test_file = ".testfile" + home_filepath = os.path.join(self.temp_home, test_file) + mackup_filepath = os.path.join(self.mock_mackup.mackup_folder, test_file) + + with open(home_filepath, "w") as f: + f.write("dotfile content") + + with patch("mackup.application.platform.system", return_value="Darwin"): + self.app_profile.link_install() + + assert os.path.exists(mackup_filepath) + assert os.path.islink(home_filepath) + assert os.path.samefile(home_filepath, mackup_filepath) + + def test_link_uses_copy_for_library_paths_on_macos(self): + """macOS should copy ~/Library files during link restore.""" + test_file = "Library/Preferences/com.test.app.plist" + app_profile = ApplicationProfile( + mackup=self.mock_mackup, + files={test_file}, + dry_run=False, + verbose=False, + ) + + home_filepath = os.path.join(self.temp_home, test_file) + mackup_filepath = os.path.join(self.mock_mackup.mackup_folder, test_file) + os.makedirs(os.path.dirname(mackup_filepath), exist_ok=True) + + with open(mackup_filepath, "w") as f: + f.write("backup plist") + + with patch("mackup.application.platform.system", return_value="Darwin"), \ + patch("mackup.application.utils.link") as mock_link: + app_profile.link() + + mock_link.assert_not_called() + assert os.path.exists(home_filepath) + assert not os.path.islink(home_filepath) + with open(home_filepath) as f: + assert f.read() == "backup plist" + + def test_link_uninstall_uses_copy_for_library_paths_on_macos(self): + """macOS should copy ~/Library files during link uninstall.""" + test_file = "Library/Preferences/com.test.app.plist" + app_profile = ApplicationProfile( + mackup=self.mock_mackup, + files={test_file}, + dry_run=False, + verbose=False, + ) + + home_filepath = os.path.join(self.temp_home, test_file) + mackup_filepath = os.path.join(self.mock_mackup.mackup_folder, test_file) + os.makedirs(os.path.dirname(home_filepath), exist_ok=True) + os.makedirs(os.path.dirname(mackup_filepath), exist_ok=True) + + with open(home_filepath, "w") as f: + f.write("old local plist") + with open(mackup_filepath, "w") as f: + f.write("backup plist") + + with patch("mackup.application.platform.system", return_value="Darwin"), \ + patch("mackup.application.utils.confirm", return_value=True): + app_profile.link_uninstall() + + assert os.path.exists(home_filepath) + assert not os.path.islink(home_filepath) + with open(home_filepath) as f: + assert f.read() == "backup plist" + if __name__ == "__main__": unittest.main()