diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1181c7127af9e..5cd29895ae999 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,6 +60,21 @@ jobs: # the login password doesn't matter, but the keyring must be unlocked for the tests to work run: gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar' + - name: "Create btrfs filesystem" + run: | + sudo apt-get install -y btrfs-progs + truncate -s 1G /tmp/btrfs.img + mkfs.btrfs /tmp/btrfs.img + sudo mkdir /btrfs + sudo mount -o loop /tmp/btrfs.img /btrfs + sudo chown "$(id -u):$(id -g)" /btrfs + + - name: "Create tmpfs filesystem" + run: | + sudo mkdir /tmpfs + sudo mount -t tmpfs -o size=256m tmpfs /tmpfs + sudo chown "$(id -u):$(id -g)" /tmpfs + - name: "Install cargo nextest" uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7 with: @@ -70,6 +85,9 @@ jobs: # Retry more than default to reduce flakes in CI UV_HTTP_RETRIES: 5 RUST_BACKTRACE: 1 + UV_INTERNAL__TEST_COW_FS: /btrfs + UV_INTERNAL__TEST_NOCOW_FS: /tmpfs + UV_INTERNAL__TEST_ALT_FS: /tmpfs run: | cargo nextest run \ --cargo-profile fast-build \ @@ -97,6 +115,12 @@ jobs: - name: "Install Rust toolchain" run: rustup show + - name: "Create HFS+ disk image (no reflink support)" + run: | + hdiutil create -size 256m -fs HFS+ -volname NoReflink /tmp/noreflink.dmg + hdiutil attach /tmp/noreflink.dmg + echo "HFS_MOUNT=/Volumes/NoReflink" >> "$GITHUB_ENV" + - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 with: version: "0.10.2" @@ -114,6 +138,11 @@ jobs: # Retry more than default to reduce flakes in CI UV_HTTP_RETRIES: 5 RUST_BACKTRACE: 1 + # macOS tmpdir is on APFS which supports reflink + UV_INTERNAL__TEST_COW_FS: ${{ runner.temp }} + # HFS+ RAM disk does not support copy-on-write and is on a different device + UV_INTERNAL__TEST_NOCOW_FS: ${{ env.HFS_MOUNT }} + UV_INTERNAL__TEST_ALT_FS: ${{ env.HFS_MOUNT }} run: | cargo nextest run \ --cargo-profile fast-build \ diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 9809a3d9c3a77..f0be332b558b2 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3236,7 +3236,7 @@ pub struct VenvArgs { /// /// This option is only used for installing seed packages. /// - /// Defaults to `clone` (also known as Copy-on-Write) on macOS, and `hardlink` on Linux and + /// Defaults to `clone` (also known as Copy-on-Write) on macOS and Linux, and `hardlink` on /// Windows. /// /// WARNING: The use of symlink link mode is discouraged, as they create tight coupling between @@ -5908,7 +5908,7 @@ pub struct ToolUpgradeArgs { /// The method to use when installing packages from the global cache. /// - /// Defaults to `clone` (also known as Copy-on-Write) on macOS, and `hardlink` on Linux and + /// Defaults to `clone` (also known as Copy-on-Write) on macOS and Linux, and `hardlink` on /// Windows. /// /// WARNING: The use of symlink link mode is discouraged, as they create tight coupling between @@ -6963,7 +6963,7 @@ pub struct InstallerArgs { /// The method to use when installing packages from the global cache. /// - /// Defaults to `clone` (also known as Copy-on-Write) on macOS, and `hardlink` on Linux and + /// Defaults to `clone` (also known as Copy-on-Write) on macOS and Linux, and `hardlink` on /// Windows. /// /// WARNING: The use of symlink link mode is discouraged, as they create tight coupling between @@ -7203,7 +7203,7 @@ pub struct ResolverArgs { /// /// This option is only used when building source distributions. /// - /// Defaults to `clone` (also known as Copy-on-Write) on macOS, and `hardlink` on Linux and + /// Defaults to `clone` (also known as Copy-on-Write) on macOS and Linux, and `hardlink` on /// Windows. /// /// WARNING: The use of symlink link mode is discouraged, as they create tight coupling between @@ -7442,7 +7442,7 @@ pub struct ResolverInstallerArgs { /// The method to use when installing packages from the global cache. /// - /// Defaults to `clone` (also known as Copy-on-Write) on macOS, and `hardlink` on Linux and + /// Defaults to `clone` (also known as Copy-on-Write) on macOS and Linux, and `hardlink` on /// Windows. /// /// WARNING: The use of symlink link mode is discouraged, as they create tight coupling between diff --git a/crates/uv-fs/src/link.rs b/crates/uv-fs/src/link.rs index 9f5e796cab21b..217e60459cc7f 100644 --- a/crates/uv-fs/src/link.rs +++ b/crates/uv-fs/src/link.rs @@ -12,8 +12,9 @@ use walkdir::WalkDir; /// The method to use when linking. /// -/// Defaults to [`Clone`](LinkMode::Clone) on macOS (since APFS supports copy-on-write), and -/// [`Hardlink`](LinkMode::Hardlink) on other platforms. +/// Defaults to [`Clone`](LinkMode::Clone) on macOS and Linux (which support copy-on-write on +/// APFS and btrfs/xfs/bcachefs respectively), and [`Hardlink`](LinkMode::Hardlink) on other +/// platforms. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr( @@ -37,7 +38,11 @@ pub enum LinkMode { impl Default for LinkMode { fn default() -> Self { - if cfg!(any(target_os = "macos", target_os = "ios")) { + if cfg!(any( + target_os = "macos", + target_os = "ios", + target_os = "linux" + )) { Self::Clone } else { Self::Hardlink @@ -839,6 +844,38 @@ mod tests { use super::*; use tempfile::TempDir; + /// Create a temporary directory on the default filesystem. + fn test_tempdir() -> TempDir { + TempDir::new().unwrap() + } + + /// Create a temporary directory on a copy-on-write filesystem. + /// + /// Returns `None` if `UV_INTERNAL__TEST_COW_FS` is not set. + fn cow_tempdir() -> Option { + let dir = std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_COW_FS).ok()?; + fs_err::create_dir_all(&dir).unwrap(); + Some(TempDir::new_in(dir).unwrap()) + } + + /// Create a temporary directory on a filesystem without copy-on-write support. + /// + /// Returns `None` if `UV_INTERNAL__TEST_NOCOW_FS` is not set. + fn nocow_tempdir() -> Option { + let dir = std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_NOCOW_FS).ok()?; + fs_err::create_dir_all(&dir).unwrap(); + Some(TempDir::new_in(dir).unwrap()) + } + + /// Create a temporary directory on an alternative filesystem. + /// + /// Returns `None` if `UV_INTERNAL__TEST_ALT_FS` is not set. + fn alt_tempdir() -> Option { + let dir = std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_ALT_FS).ok()?; + fs_err::create_dir_all(&dir).unwrap(); + Some(TempDir::new_in(dir).unwrap()) + } + /// Create a test directory structure with some files. fn create_test_tree(root: &Path) { fs_err::create_dir_all(root.join("subdir")).unwrap(); @@ -868,8 +905,8 @@ mod tests { #[test] fn test_copy_dir_basic() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -882,8 +919,8 @@ mod tests { #[test] fn test_hardlink_dir_basic() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -907,8 +944,8 @@ mod tests { #[test] #[cfg(unix)] // Symlinks require special permissions on Windows fn test_symlink_dir_basic() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -927,8 +964,8 @@ mod tests { #[test] fn test_clone_dir_basic() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -955,20 +992,21 @@ mod tests { } #[test] - fn test_reflink_file_when_supported() { - let tmp_dir = TempDir::new().unwrap(); - - if !reflink_supported(tmp_dir.path()) { - eprintln!("Skipping test: reflink not supported on this filesystem"); + fn test_reflink_file_on_reflink_fs() { + let Some(tmp_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); return; - } + }; + + assert!( + reflink_supported(tmp_dir.path()), + "UV_INTERNAL__TEST_COW_FS points to a filesystem that does not support reflink" + ); - // Create source file let src = tmp_dir.path().join("src.txt"); let dst = tmp_dir.path().join("dst.txt"); fs_err::write(&src, "reflink content").unwrap(); - // Reflink should succeed reflink_copy::reflink(&src, &dst).unwrap(); assert_eq!(fs_err::read_to_string(&dst).unwrap(), "reflink content"); @@ -980,21 +1018,25 @@ mod tests { } #[test] - fn test_clone_dir_reflink_when_supported() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); - - if !reflink_supported(src_dir.path()) { - eprintln!("Skipping test: reflink not supported on this filesystem"); + fn test_clone_dir_reflink_on_reflink_fs() { + let Some(src_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); return; - } + }; + let Some(dst_dir) = cow_tempdir() else { + unreachable!(); + }; + + assert!( + reflink_supported(src_dir.path()), + "UV_INTERNAL__TEST_COW_FS points to a filesystem that does not support reflink" + ); create_test_tree(src_dir.path()); let options = LinkOptions::new(LinkMode::Clone); let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); - // On supported filesystems, clone should succeed assert_eq!(result, LinkMode::Clone); verify_test_tree(dst_dir.path()); @@ -1007,14 +1049,19 @@ mod tests { } #[test] - fn test_clone_merge_when_supported() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); - - if !reflink_supported(src_dir.path()) { - eprintln!("Skipping test: reflink not supported on this filesystem"); + fn test_clone_merge_on_reflink_fs() { + let Some(src_dir) = cow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set"); return; - } + }; + let Some(dst_dir) = cow_tempdir() else { + unreachable!(); + }; + + assert!( + reflink_supported(src_dir.path()), + "UV_INTERNAL__TEST_COW_FS points to a filesystem that does not support reflink" + ); create_test_tree(src_dir.path()); @@ -1041,13 +1088,161 @@ mod tests { ); } + #[test] + fn test_clone_fallback_on_nocow_fs() { + let Some(src_dir) = nocow_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_NOCOW_FS not set"); + return; + }; + let Some(dst_dir) = nocow_tempdir() else { + unreachable!(); + }; + + assert!( + !reflink_supported(src_dir.path()), + "UV_INTERNAL__TEST_NOCOW_FS points to a filesystem that supports reflink" + ); + + create_test_tree(src_dir.path()); + + let options = LinkOptions::new(LinkMode::Clone); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + assert!( + result == LinkMode::Hardlink || result == LinkMode::Copy, + "Expected fallback to Hardlink or Copy on non-reflink fs, got {result:?}" + ); + verify_test_tree(dst_dir.path()); + } + + /// Clone across filesystems must fall back to copy. + #[test] + fn test_clone_cross_device() { + let Some(src_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + let dst_dir = test_tempdir(); + + create_test_tree(src_dir.path()); + + // When linking across devices, we must fallback to copy + let options = LinkOptions::new(LinkMode::Clone); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + assert_eq!( + result, + LinkMode::Copy, + "Expected fallback to Copy for cross-device clone, got {result:?}" + ); + verify_test_tree(dst_dir.path()); + } + + /// Hardlink across filesystems must fall back to copy. + #[test] + fn test_hardlink_cross_device() { + let Some(src_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + let dst_dir = test_tempdir(); + + create_test_tree(src_dir.path()); + + // When linking across devices, we must fallback to copy + let options = LinkOptions::new(LinkMode::Hardlink); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + assert_eq!( + result, + LinkMode::Copy, + "Expected fallback to Copy for cross-device hardlink, got {result:?}" + ); + verify_test_tree(dst_dir.path()); + } + + /// Clone merge across filesystems must fall back to copy. + #[test] + fn test_clone_merge_cross_device() { + let Some(src_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + let dst_dir = test_tempdir(); + + create_test_tree(src_dir.path()); + + // Pre-create destination with existing content + fs_err::create_dir_all(dst_dir.path()).unwrap(); + fs_err::write(dst_dir.path().join("file1.txt"), "old content").unwrap(); + fs_err::write(dst_dir.path().join("extra.txt"), "extra").unwrap(); + + let options = LinkOptions::new(LinkMode::Clone) + .with_on_existing_directory(OnExistingDirectory::Merge); + + // When linking across devices, we must fallback to copy + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + assert_eq!( + result, + LinkMode::Copy, + "Expected fallback to Copy for cross-device clone merge, got {result:?}" + ); + + // Source files should overwrite destination + assert_eq!( + fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(), + "content1" + ); + // Extra file should remain + assert_eq!( + fs_err::read_to_string(dst_dir.path().join("extra.txt")).unwrap(), + "extra" + ); + } + + /// Copy mode works across filesystems. + #[test] + fn test_copy_cross_device() { + let Some(src_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + let dst_dir = test_tempdir(); + + create_test_tree(src_dir.path()); + + let options = LinkOptions::new(LinkMode::Copy); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + assert_eq!(result, LinkMode::Copy); + verify_test_tree(dst_dir.path()); + } + + /// Symlink across filesystems should work (symlinks can span devices). + #[test] + #[cfg(unix)] + fn test_symlink_cross_device() { + let Some(src_dir) = alt_tempdir() else { + eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set"); + return; + }; + let dst_dir = test_tempdir(); + + create_test_tree(src_dir.path()); + + let options = LinkOptions::new(LinkMode::Symlink); + let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); + + // Symlinks work across devices + assert_eq!(result, LinkMode::Symlink); + verify_test_tree(dst_dir.path()); + } + #[test] fn test_reflink_fallback_to_hardlink() { // This test verifies the fallback behavior when reflink fails. // We can't easily force reflink to fail on a supporting filesystem, // so we just verify the clone path works and returns a valid mode. - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1064,8 +1259,8 @@ mod tests { #[test] fn test_merge_overwrites_existing_files() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create source create_test_tree(src_dir.path()); @@ -1093,8 +1288,8 @@ mod tests { #[test] fn test_fail_mode_errors_on_existing_hardlink() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1122,8 +1317,8 @@ mod tests { #[test] fn test_copy_mode_overwrites_in_fail_mode() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1145,8 +1340,8 @@ mod tests { #[test] fn test_mutable_copy_filter() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); // Add a RECORD file that should be copied, not linked @@ -1182,8 +1377,8 @@ mod tests { #[test] fn test_synchronized_copy() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1197,8 +1392,8 @@ mod tests { #[test] fn test_empty_directory() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create empty subdirectory fs_err::create_dir_all(src_dir.path().join("empty_subdir")).unwrap(); @@ -1211,8 +1406,8 @@ mod tests { #[test] fn test_nested_directories() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create deeply nested structure let deep_path = src_dir.path().join("a/b/c/d/e"); @@ -1230,8 +1425,8 @@ mod tests { #[test] fn test_hardlink_merge_with_existing() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1257,8 +1452,8 @@ mod tests { use std::sync::Arc; use std::thread; - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create a file to copy fs_err::write(src_dir.path().join("file.txt"), "content").unwrap(); @@ -1296,8 +1491,8 @@ mod tests { #[test] #[cfg(unix)] fn test_symlink_merge_with_existing() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1326,8 +1521,8 @@ mod tests { #[test] #[cfg(unix)] fn test_symlink_mutable_copy_filter() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); fs_err::write(src_dir.path().join("RECORD"), "record content").unwrap(); @@ -1352,8 +1547,8 @@ mod tests { #[test] fn test_source_not_found() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Don't create any files in src_dir, just use a non-existent path let nonexistent = src_dir.path().join("nonexistent"); @@ -1368,8 +1563,8 @@ mod tests { fn test_clone_mutable_copy_filter_ignored() { // The mutable_copy filter only applies to hardlink/symlink modes. // For clone/copy modes, all files are already mutable (copy-on-write or full copy). - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); fs_err::write(src_dir.path().join("RECORD"), "record content").unwrap(); @@ -1389,8 +1584,8 @@ mod tests { #[test] fn test_copy_mutable_copy_filter_ignored() { // For copy mode, all files are already mutable, so filter is ignored - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); fs_err::write(src_dir.path().join("RECORD"), "record content").unwrap(); @@ -1408,8 +1603,8 @@ mod tests { #[test] fn test_special_characters_in_filenames() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create files with special characters (that are valid on most filesystems) fs_err::write(src_dir.path().join("file with spaces.txt"), "spaces").unwrap(); @@ -1436,8 +1631,8 @@ mod tests { #[test] fn test_hidden_files() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create hidden files (dotfiles) fs_err::write(src_dir.path().join(".hidden"), "hidden content").unwrap(); @@ -1466,8 +1661,8 @@ mod tests { #[cfg(target_os = "macos")] fn test_macos_clone_directory_recursive() { // Test the macOS-specific directory cloning via clonefile - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1484,8 +1679,8 @@ mod tests { #[cfg(target_os = "macos")] fn test_macos_clone_dir_merge_nested() { // Test the macOS clone_dir_merge with nested directory structure - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create nested structure in source fs_err::create_dir_all(src_dir.path().join("a/b/c")).unwrap(); @@ -1527,8 +1722,8 @@ mod tests { #[cfg(target_os = "macos")] fn test_macos_clone_merge_overwrites_files() { // Test that clone merge properly overwrites existing files on macOS - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); fs_err::write(src_dir.path().join("file.txt"), "new content").unwrap(); @@ -1548,8 +1743,8 @@ mod tests { #[test] fn test_clone_fail_mode_on_existing() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1576,8 +1771,8 @@ mod tests { #[test] #[cfg(unix)] fn test_symlink_fail_mode_on_existing() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); create_test_tree(src_dir.path()); @@ -1598,35 +1793,12 @@ mod tests { } } - #[test] - fn test_clone_fallback_when_reflink_unsupported() { - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); - - if reflink_supported(src_dir.path()) { - eprintln!("Skipping test: reflink is supported on this filesystem"); - return; - } - - create_test_tree(src_dir.path()); - - let options = LinkOptions::new(LinkMode::Clone); - let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap(); - - // When reflink is not supported, should fall back to hardlink or copy - assert!( - result == LinkMode::Hardlink || result == LinkMode::Copy, - "Expected fallback to Hardlink or Copy, got {result:?}" - ); - verify_test_tree(dst_dir.path()); - } - #[test] #[cfg(windows)] fn test_windows_symlink_file_vs_dir() { // Test that Windows correctly uses symlink_file for files and symlink_dir for directories - let src_dir = TempDir::new().unwrap(); - let dst_dir = TempDir::new().unwrap(); + let src_dir = test_tempdir(); + let dst_dir = test_tempdir(); // Create a file and a directory fs_err::write(src_dir.path().join("file.txt"), "content").unwrap(); diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 8d07ecaf747b9..11a5d88f7a35e 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -880,7 +880,7 @@ pub struct ResolverInstallerSchema { pub exclude_newer_package: Option, /// The method to use when installing packages from the global cache. /// - /// Defaults to `clone` (also known as Copy-on-Write) on macOS, and `hardlink` on Linux and + /// Defaults to `clone` (also known as Copy-on-Write) on macOS and Linux, and `hardlink` on /// Windows. /// /// WARNING: The use of symlink link mode is discouraged, as they create tight coupling between @@ -888,7 +888,7 @@ pub struct ResolverInstallerSchema { /// will break all installed packages by way of removing the underlying source files. Use /// symlinks with caution. #[option( - default = "\"clone\" (macOS) or \"hardlink\" (Linux, Windows)", + default = "\"clone\" (macOS, Linux) or \"hardlink\" (Windows)", value_type = "str", example = r#" link-mode = "copy" @@ -1747,7 +1747,7 @@ pub struct PipOptions { pub annotation_style: Option, /// The method to use when installing packages from the global cache. /// - /// Defaults to `clone` (also known as Copy-on-Write) on macOS, and `hardlink` on Linux and + /// Defaults to `clone` (also known as Copy-on-Write) on macOS and Linux, and `hardlink` on /// Windows. /// /// WARNING: The use of symlink link mode is discouraged, as they create tight coupling between @@ -1755,7 +1755,7 @@ pub struct PipOptions { /// will break all installed packages by way of removing the underlying source files. Use /// symlinks with caution. #[option( - default = "\"clone\" (macOS) or \"hardlink\" (Linux, Windows)", + default = "\"clone\" (macOS, Linux) or \"hardlink\" (Windows)", value_type = "str", example = r#" link-mode = "copy" diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 72b293be56567..a0f2ed208a23c 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -558,6 +558,30 @@ impl EnvVars { #[attr_added_in("0.3.4")] pub const UV_INTERNAL__TEST_DIR: &'static str = "UV_INTERNAL__TEST_DIR"; + /// Path to a directory on a filesystem that supports copy-on-write, e.g., btrfs or APFS. + /// + /// When populated, uv will run additional tests that require this functionality. + #[attr_hidden] + #[attr_added_in("next release")] + pub const UV_INTERNAL__TEST_COW_FS: &'static str = "UV_INTERNAL__TEST_COW_FS"; + + /// Path to a directory on a filesystem that does **not** support copy-on-write. + /// + /// When populated, uv will run additional tests that verify fallback behavior + /// when copy-on-write is unavailable. + #[attr_hidden] + #[attr_added_in("next release")] + pub const UV_INTERNAL__TEST_NOCOW_FS: &'static str = "UV_INTERNAL__TEST_NOCOW_FS"; + + /// Path to a directory on an alternative filesystem for testing. + /// + /// This filesystem must be a different device than the default for the test suite. + /// + /// When populated, uv will run additional tests that cover cross-filesystem linking. + #[attr_hidden] + #[attr_added_in("next release")] + pub const UV_INTERNAL__TEST_ALT_FS: &'static str = "UV_INTERNAL__TEST_ALT_FS"; + /// Used to force treating an interpreter as "managed" during tests. #[attr_hidden] #[attr_added_in("0.8.0")] diff --git a/crates/uv-test/src/lib.rs b/crates/uv-test/src/lib.rs index c0ccab398e765..123ea3c109e51 100755 --- a/crates/uv-test/src/lib.rs +++ b/crates/uv-test/src/lib.rs @@ -166,6 +166,11 @@ pub struct TestContext { #[allow(dead_code)] _root: tempfile::TempDir, + + /// Extra temporary directories whose lifetimes are tied to this context (e.g., directories + /// on alternate filesystems created by [`TestContext::with_cache_on_cow_fs`]). + #[allow(dead_code)] + _extra_tempdirs: Vec, } impl TestContext { @@ -684,6 +689,115 @@ impl TestContext { self } + /// Use a cache directory on the filesystem specified by + /// [`EnvVars::UV_INTERNAL__TEST_COW_FS`]. + /// + /// Returns `Ok(None)` if the environment variable is not set. + pub fn with_cache_on_cow_fs(self) -> anyhow::Result> { + let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_COW_FS).ok() else { + return Ok(None); + }; + self.with_cache_on_fs(&dir, "COW_FS").map(Some) + } + + /// Use a cache directory on the filesystem specified by + /// [`EnvVars::UV_INTERNAL__TEST_ALT_FS`]. + /// + /// Returns `Ok(None)` if the environment variable is not set. + pub fn with_cache_on_alt_fs(self) -> anyhow::Result> { + let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_ALT_FS).ok() else { + return Ok(None); + }; + self.with_cache_on_fs(&dir, "ALT_FS").map(Some) + } + + /// Use a cache directory on the filesystem specified by + /// [`EnvVars::UV_INTERNAL__TEST_NOCOW_FS`]. + /// + /// Returns `Ok(None)` if the environment variable is not set. + pub fn with_cache_on_nocow_fs(self) -> anyhow::Result> { + let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_NOCOW_FS).ok() else { + return Ok(None); + }; + self.with_cache_on_fs(&dir, "NOCOW_FS").map(Some) + } + + /// Use a working directory on the filesystem specified by + /// [`EnvVars::UV_INTERNAL__TEST_COW_FS`]. + /// + /// Returns `Ok(None)` if the environment variable is not set. + /// + /// Note a virtual environment is not created automatically. + pub fn with_working_dir_on_cow_fs(self) -> anyhow::Result> { + let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_COW_FS).ok() else { + return Ok(None); + }; + self.with_working_dir_on_fs(&dir, "COW_FS").map(Some) + } + + /// Use a working directory on the filesystem specified by + /// [`EnvVars::UV_INTERNAL__TEST_ALT_FS`]. + /// + /// Returns `Ok(None)` if the environment variable is not set. + /// + /// Note a virtual environment is not created automatically. + pub fn with_working_dir_on_alt_fs(self) -> anyhow::Result> { + let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_ALT_FS).ok() else { + return Ok(None); + }; + self.with_working_dir_on_fs(&dir, "ALT_FS").map(Some) + } + + /// Use a working directory on the filesystem specified by + /// [`EnvVars::UV_INTERNAL__TEST_NOCOW_FS`]. + /// + /// Returns `Ok(None)` if the environment variable is not set. + /// + /// Note a virtual environment is not created automatically. + pub fn with_working_dir_on_nocow_fs(self) -> anyhow::Result> { + let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_NOCOW_FS).ok() else { + return Ok(None); + }; + self.with_working_dir_on_fs(&dir, "NOCOW_FS").map(Some) + } + + fn with_cache_on_fs(mut self, dir: &str, name: &str) -> anyhow::Result { + fs_err::create_dir_all(dir)?; + let tmp = tempfile::TempDir::new_in(dir)?; + self.cache_dir = ChildPath::new(tmp.path()).child("cache"); + fs_err::create_dir_all(&self.cache_dir)?; + let replacement = format!("[{name}]/[CACHE_DIR]/"); + self.filters.extend( + Self::path_patterns(&self.cache_dir) + .into_iter() + .map(|pattern| (pattern, replacement.clone())), + ); + self._extra_tempdirs.push(tmp); + Ok(self) + } + + fn with_working_dir_on_fs(mut self, dir: &str, name: &str) -> anyhow::Result { + fs_err::create_dir_all(dir)?; + let tmp = tempfile::TempDir::new_in(dir)?; + self.temp_dir = ChildPath::new(tmp.path()).child("temp"); + fs_err::create_dir_all(&self.temp_dir)?; + self.venv = ChildPath::new(tmp.path().canonicalize()?.join(".venv")); + let temp_replacement = format!("[{name}]/[TEMP_DIR]/"); + self.filters.extend( + Self::path_patterns(&self.temp_dir) + .into_iter() + .map(|pattern| (pattern, temp_replacement.clone())), + ); + let venv_replacement = format!("[{name}]/[VENV]/"); + self.filters.extend( + Self::path_patterns(&self.venv) + .into_iter() + .map(|pattern| (pattern, venv_replacement.clone())), + ); + self._extra_tempdirs.push(tmp); + Ok(self) + } + /// Default to the canonicalized path to the temp directory. We need to do this because on /// macOS (and Windows on GitHub Actions) the standard temp dir is a symlink. (On macOS, the /// temporary directory is, like `/var/...`, which resolves to `/private/var/...`.) @@ -980,6 +1094,7 @@ impl TestContext { filters, extra_env: vec![], _root: root, + _extra_tempdirs: vec![], } } diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 71dcf8fcbc313..6560d20a0a1c2 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -14199,3 +14199,157 @@ fn warn_on_lzma_wheel() { " ); } + +/// Install a package with the cache on a different filesystem than the venv. +/// This exercises the cross-device fallback path. +/// +/// Requires `UV_INTERNAL__TEST_ALT_FS`. +#[test] +fn install_cross_device() -> anyhow::Result<()> { + let Some(context) = uv_test::test_context!("3.12").with_cache_on_alt_fs()? else { + return Ok(()); + }; + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("iniconfig"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + warning: Failed to hardlink files; falling back to full copy. This may lead to degraded performance. + If the cache and target directories are on different filesystems, hardlinking may not be supported. + If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning. + Installed 1 package in [TIME] + + iniconfig==2.0.0 + " + ); + + Ok(()) +} + +/// Install a package across filesystems with `--link-mode copy`. +/// The warning should not appear since copy mode is explicitly requested. +/// +/// Requires `UV_INTERNAL__TEST_ALT_FS`. +#[test] +fn install_cross_device_explicit_copy() -> anyhow::Result<()> { + let Some(context) = uv_test::test_context!("3.12").with_cache_on_alt_fs()? else { + return Ok(()); + }; + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("--link-mode") + .arg("copy") + .arg("iniconfig"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + " + ); + + Ok(()) +} + +/// Install a package across filesystems with `--link-mode symlink`. +/// Symlinks work across devices so no fallback warning should appear. +/// +/// Requires `UV_INTERNAL__TEST_ALT_FS`. +#[test] +#[cfg(unix)] +fn install_cross_device_symlink() -> anyhow::Result<()> { + let Some(context) = uv_test::test_context!("3.12").with_cache_on_alt_fs()? else { + return Ok(()); + }; + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("--link-mode") + .arg("symlink") + .arg("iniconfig"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + " + ); + + Ok(()) +} + +/// Install a package with both cache and venv on a copy-on-write filesystem. +/// +/// Requires `UV_INTERNAL__TEST_COW_FS`. +#[test] +fn install_copy_on_write_fs() -> anyhow::Result<()> { + let Some(context) = uv_test::test_context!("3.12").with_cache_on_cow_fs()? else { + return Ok(()); + }; + let Some(context) = context.with_working_dir_on_cow_fs()? else { + return Ok(()); + }; + context.venv().assert().success(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("iniconfig"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + " + ); + + Ok(()) +} + +/// Install a package with both cache and venv on a filesystem without copy-on-write support. +/// +/// Requires `UV_INTERNAL__TEST_NOCOW_FS`. +#[test] +fn install_no_copy_on_write_fs() -> anyhow::Result<()> { + let Some(context) = uv_test::test_context!("3.12").with_cache_on_nocow_fs()? else { + return Ok(()); + }; + let Some(context) = context.with_working_dir_on_nocow_fs()? else { + return Ok(()); + }; + context.venv().assert().success(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("iniconfig"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + " + ); + + Ok(()) +} diff --git a/docs/guides/integration/docker.md b/docs/guides/integration/docker.md index 0abfd823ad348..ba34c837a3822 100644 --- a/docs/guides/integration/docker.md +++ b/docs/guides/integration/docker.md @@ -346,8 +346,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync ``` -Changing the default [`UV_LINK_MODE`](../../reference/settings.md#link-mode) silences warnings about -not being able to use hard links since the cache and sync target are on separate file systems. +Changing the [`UV_LINK_MODE`](../../reference/settings.md#link-mode) silences warnings about not +being able to link files since the cache and sync target are on separate file systems. If you're not mounting the cache, image size can be reduced by using the `--no-cache` flag or setting `UV_NO_CACHE`.