diff --git a/Library/Homebrew/cask/artifact/moved.rb b/Library/Homebrew/cask/artifact/moved.rb index 58ae0e9d95..53d09bed19 100644 --- a/Library/Homebrew/cask/artifact/moved.rb +++ b/Library/Homebrew/cask/artifact/moved.rb @@ -34,8 +34,33 @@ module Cask private - def move(force: false, command: nil, **options) + def move(adopt: false, force: false, verbose: false, command: nil, **options) + unless source.exist? + raise CaskError, "It seems the #{self.class.english_name} source '#{source}' is not there." + end + if Utils.path_occupied?(target) + if adopt + ohai "Adopting existing #{self.class.english_name} at '#{target}'" + same = command.run( + "/usr/bin/diff", + args: ["--recursive", "--brief", source, target], + verbose: verbose, + print_stdout: verbose, + ).success? + + unless same + raise CaskError, + "It seems the existing #{self.class.english_name} is different from " \ + "the one being installed." + end + + # Remove the source as we don't need to move it to the target location + source.rmtree + + return post_move(command) + end + message = "It seems there is already #{self.class.english_article} " \ "#{self.class.english_name} at '#{target}'" raise CaskError, "#{message}." unless force @@ -44,10 +69,6 @@ module Cask delete(target, force: force, command: command, **options) end - unless source.exist? - raise CaskError, "It seems the #{self.class.english_name} source '#{source}' is not there." - end - ohai "Moving #{self.class.english_name} '#{source.basename}' to '#{target}'" if target.dirname.ascend.find(&:directory?).writable? target.dirname.mkpath @@ -61,6 +82,11 @@ module Cask command.run!("/bin/mv", args: [source, target], sudo: true) end + post_move(command) + end + + # Performs any actions necessary after the source has been moved to the target location. + def post_move(command) FileUtils.ln_sf target, source add_altname_metadata(target, source.basename, command: command) diff --git a/Library/Homebrew/cask/cmd/install.rb b/Library/Homebrew/cask/cmd/install.rb index a52b412391..e19e64eb01 100644 --- a/Library/Homebrew/cask/cmd/install.rb +++ b/Library/Homebrew/cask/cmd/install.rb @@ -10,6 +10,10 @@ module Cask extend T::Sig OPTIONS = [ + [:switch, "--adopt", { + description: "Adopt existing artifacts in the destination that are identical to those being installed. " \ + "Cannot be combined with --force.", + }], [:switch, "--skip-cask-deps", { description: "Skip installing cask dependencies.", }], @@ -40,6 +44,7 @@ module Cask binaries: args.binaries?, verbose: args.verbose?, force: args.force?, + adopt: args.adopt?, skip_cask_deps: args.skip_cask_deps?, require_sha: args.require_sha?, quarantine: args.quarantine?, @@ -52,6 +57,7 @@ module Cask *casks, verbose: nil, force: nil, + adopt: nil, binaries: nil, skip_cask_deps: nil, require_sha: nil, @@ -65,6 +71,7 @@ module Cask options = { verbose: verbose, force: force, + adopt: adopt, binaries: binaries, skip_cask_deps: skip_cask_deps, require_sha: require_sha, diff --git a/Library/Homebrew/cask/installer.rb b/Library/Homebrew/cask/installer.rb index c0417e311d..5c92615e9e 100644 --- a/Library/Homebrew/cask/installer.rb +++ b/Library/Homebrew/cask/installer.rb @@ -20,7 +20,7 @@ module Cask extend Predicable - def initialize(cask, command: SystemCommand, force: false, + def initialize(cask, command: SystemCommand, force: false, adopt: false, skip_cask_deps: false, binaries: true, verbose: false, zap: false, require_sha: false, upgrade: false, installed_as_dependency: false, quarantine: true, @@ -28,6 +28,7 @@ module Cask @cask = cask @command = command @force = force + @adopt = adopt @skip_cask_deps = skip_cask_deps @binaries = binaries @verbose = verbose @@ -41,7 +42,7 @@ module Cask @quiet = quiet end - attr_predicate :binaries?, :force?, :skip_cask_deps?, :require_sha?, + attr_predicate :binaries?, :force?, :adopt?, :skip_cask_deps?, :require_sha?, :reinstall?, :upgrade?, :verbose?, :zap?, :installed_as_dependency?, :quarantine?, :quiet? @@ -237,7 +238,7 @@ module Cask next if artifact.is_a?(Artifact::Binary) && !binaries? - artifact.install_phase(command: @command, verbose: verbose?, force: force?) + artifact.install_phase(command: @command, verbose: verbose?, adopt: adopt?, force: force?) already_installed_artifacts.unshift(artifact) end @@ -348,6 +349,7 @@ module Cask Installer.new( cask_or_formula, + adopt: adopt?, binaries: binaries?, verbose: verbose?, installed_as_dependency: true, diff --git a/Library/Homebrew/cmd/install.rb b/Library/Homebrew/cmd/install.rb index f9eeb6a0b0..be9fb54dca 100644 --- a/Library/Homebrew/cmd/install.rb +++ b/Library/Homebrew/cmd/install.rb @@ -140,6 +140,7 @@ module Homebrew conflicts "--ignore-dependencies", "--only-dependencies" conflicts "--build-from-source", "--build-bottle", "--force-bottle" + conflicts "--adopt", "--force" named_args [:formula, :cask], min: 1 end @@ -193,6 +194,7 @@ module Homebrew binaries: args.binaries?, verbose: args.verbose?, force: args.force?, + adopt: args.adopt?, require_sha: args.require_sha?, skip_cask_deps: args.skip_cask_deps?, quarantine: args.quarantine?, diff --git a/Library/Homebrew/test/cask/artifact/app_spec.rb b/Library/Homebrew/test/cask/artifact/app_spec.rb index 23a5cd944f..d9960b82e4 100644 --- a/Library/Homebrew/test/cask/artifact/app_spec.rb +++ b/Library/Homebrew/test/cask/artifact/app_spec.rb @@ -4,13 +4,14 @@ describe Cask::Artifact::App, :cask do let(:cask) { Cask::CaskLoader.load(cask_path("local-caffeine")) } let(:command) { SystemCommand } + let(:adopt) { false } let(:force) { false } let(:app) { cask.artifacts.find { |a| a.is_a?(described_class) } } let(:source_path) { cask.staged_path.join("Caffeine.app") } let(:target_path) { cask.config.appdir.join("Caffeine.app") } - let(:install_phase) { app.install_phase(command: command, force: force) } + let(:install_phase) { app.install_phase(command: command, adopt: adopt, force: force) } let(:uninstall_phase) { app.uninstall_phase(command: command, force: force) } before do @@ -79,6 +80,57 @@ describe Cask::Artifact::App, :cask do expect(contents_path).not_to exist end + describe "given the adopt option" do + let(:adopt) { true } + + describe "when the target compares different from the source" do + it "avoids clobbering the existing app" do + stdout = <<~EOS + ==> Adopting existing App at '#{target_path}' + EOS + + expect { install_phase } + .to output(stdout).to_stdout + .and raise_error( + Cask::CaskError, + "It seems the existing App is different from the one being installed.", + ) + + expect(source_path).to be_a_directory + expect(target_path).to be_a_directory + expect(File.identical?(source_path, target_path)).to be false + + contents_path = target_path.join("Contents/Info.plist") + expect(contents_path).not_to exist + end + end + + describe "when the target compares the same as the source" do + before do + target_path.delete + FileUtils.cp_r source_path, target_path + end + + it "adopts the existing app" do + stdout = <<~EOS + ==> Adopting existing App at '#{target_path}' + EOS + + stderr = "" + + expect { install_phase } + .to output(stdout).to_stdout + .and output(stderr).to_stderr + + expect(source_path).to be_a_symlink + expect(target_path).to be_a_directory + + contents_path = target_path.join("Contents/Info.plist") + expect(contents_path).to exist + end + end + end + describe "given the force option" do let(:force) { true }