diff --git a/Library/Homebrew/unpack_strategy.rb b/Library/Homebrew/unpack_strategy.rb index 5faede39483569..55c856e24eb7b1 100644 --- a/Library/Homebrew/unpack_strategy.rb +++ b/Library/Homebrew/unpack_strategy.rb @@ -183,7 +183,7 @@ def extract_nestedly(to: nil, basename: nil, verbose: false, prioritize_extensio FileUtils.chmod "u+w", path, verbose: end - Directory.new(tmp_unpack_dir).extract(to:, verbose:) + Directory.new(tmp_unpack_dir, move: true).extract(to:, verbose:) end end diff --git a/Library/Homebrew/unpack_strategy/directory.rb b/Library/Homebrew/unpack_strategy/directory.rb index 8db0bc9fde4db2..8c3b023b52f03a 100644 --- a/Library/Homebrew/unpack_strategy/directory.rb +++ b/Library/Homebrew/unpack_strategy/directory.rb @@ -16,33 +16,55 @@ def self.can_extract?(path) path.directory? end + sig { + params( + path: T.any(String, Pathname), + ref_type: T.nilable(Symbol), + ref: T.nilable(String), + merge_xattrs: T::Boolean, + move: T::Boolean, + ).void + } + def initialize(path, ref_type: nil, ref: nil, merge_xattrs: false, move: false) + super(path, ref_type:, ref:, merge_xattrs:) + @move = move + end + private sig { override.params(unpack_dir: Pathname, basename: Pathname, verbose: T::Boolean).void } def extract_to_dir(unpack_dir, basename:, verbose:) - path_children = path.children - return if path_children.empty? - - existing = unpack_dir.children - - # We run a few cp attempts in the following order: - # - # 1. Start with `-al` to create hardlinks rather than copying files if the source and - # target are on the same filesystem. On macOS, this is the only cp option that can - # preserve hardlinks but it is only available since macOS 12.3 (file_cmds-353.100.22). - # 2. Try `-a` as GNU `cp -a` preserves hardlinks. macOS `cp -a` is identical to `cp -pR`. - # 3. Fall back on `-pR` to handle the case where GNU `cp -a` failed. This may happen if - # installing into a filesystem that doesn't support hardlinks like an exFAT USB drive. - cp_arg_attempts = ["-a", "-pR"] - cp_arg_attempts.unshift("-al") if path.stat.dev == unpack_dir.stat.dev - - cp_arg_attempts.each do |arg| - args = [arg, *path_children, unpack_dir] - must_succeed = print_stderr = (arg == cp_arg_attempts.last) - result = system_command("cp", args:, verbose:, must_succeed:, print_stderr:) - break if result.success? - - FileUtils.rm_r(unpack_dir.children - existing) + if @move + path.find(ignore_error: false) do |src| + next if src == path + + dst = unpack_dir/src.relative_path_from(path) + + if dst.exist? + dst_real_dir = dst.directory? && !dst.symlink? + src_real_dir = src.directory? && !src.symlink? + # Avoid trying to move a non-directory over an existing directory or vice versa. + # This is similar to `cp` which fails with 'cp: : Not a directory'. + # However, unlike `cp`, this will fail early rather than at the end. + raise "Cannot extract when only one of #{src} and #{dst} is a directory" if dst_real_dir ^ src_real_dir + + # Remove non-directory and just let `mv` handle to simplify handling + dst.unlink unless dst_real_dir + end + + # Defer writing over existing directories to let `cp` handle preserving information + unless dst.directory? + FileUtils.mv(src, dst) + Find.prune + end + end + end + + path.each_child do |child| + system_command! "cp", + args: ["-pR", (child.directory? && !child.symlink?) ? "#{child}/." : child, + unpack_dir/child.basename], + verbose: end end end