diff --git a/lib/omnibus/builder.rb b/lib/omnibus/builder.rb index cc564e384..8317c61b7 100644 --- a/lib/omnibus/builder.rb +++ b/lib/omnibus/builder.rb @@ -15,8 +15,9 @@ # require 'fileutils' -require 'ostruct' require 'mixlib/shellout' +require 'ostruct' +require 'pathname' module Omnibus class Builder @@ -493,6 +494,80 @@ def link(source, destination, options = {}) end expose :link + # + # Copy the files from +source+ to +destination+, while removing any files + # in +destination+ that are not present in +source+. + # + # You can pass the option +:exclude+ option to ignore files and folders that + # match the given pattern(s). Note the exclude pattern behaves on paths + # relative to the given source. If you want to exclude a nested directory, + # you will need to use something like +**/directory+. + # + # @example + # sync "#{project_dir}/**/*.rb", "#{install_dir}/ruby_files" + # + # @example + # sync project_dir, "#{install_dir}/files", exclude: '.git' + # + # @param [String] source + # the path on disk to sync from + # @param [String] destination + # the path on disk to sync to + # + # @option options [String, Array] :exclude + # a file, folder, or globbing pattern of files to ignore when syncing + # + # @return (see #command) + # + def sync(source, destination, options = {}) + build_commands << BuildCommand.new("sync `#{source}' to `#{destination}'") do + Dir.chdir(software.install_dir) do + # The source must be a destination in the sync command + unless File.directory?(source) + raise ArgumentError, "`source' must be a directory, but was a " \ + "`#{File.ftype(source)}'! If you just want to sync a file, use " \ + "the `copy' method instead." + end + + # Reject any files that match the excludes pattern + excludes = Array(options[:exclude]).map do |exclude| + [exclude, "#{exclude}/*"] + end.flatten + + source_files = all_files(source) + source_files = source_files.reject do |source_file| + basename = relative_path_for(source_file, source) + excludes.any? { |exclude| File.fnmatch?(exclude, basename, File::FNM_DOTMATCH) } + end + + # Ensure the destination directory exists + FileUtils.mkdir_p(destination) unless File.directory?(destination) + + # Copy over the filtered source files + FileUtils.cp_r(source_files, destination) + + # Remove any files in the destination that are not in the source files + destination_files = all_files(destination) + + # Calculate the relative paths of files so we can compare to the + # source. + relative_source_files = source_files.map do |file| + relative_path_for(file, source) + end + relative_destination_files = destination_files.map do |file| + relative_path_for(file, destination) + end + + # Remove any extra files that are present in the destination, but are + # not in the source list + extra_files = relative_destination_files - relative_source_files + extra_files.each do |file| + FileUtils.rm_rf(File.join(destination, file)) + end + end + end + end + # # @!endgroup # -------------------------------------------------- @@ -744,6 +819,38 @@ def find_file(path, source) [candidate_paths, file] end + # + # Get all the regular files and directories at the given path. It is assumed + # this path is a fully-qualified path and/or executed from a proper relative + # path. + # + # @param [String] path + # the path to get all files from + # + # @return [Array] + # the list of all files + # + def all_files(path) + Dir.glob("#{path}/**/*", File::FNM_DOTMATCH).reject do |file| + basename = File.basename(file) + IGNORED_FILES.include?(basename) + end + end + + # + # The relative path of the given +path+ to the +parent+. + # + # @param [String] path + # the path to get relative with + # @param [String] parent + # the parent where the path is contained (hopefully) + # + # @return [String] + # + def relative_path_for(path, parent) + Pathname.new(path).relative_path_from(Pathname.new(parent)).to_s + end + # # The log key for this class, overriden to incorporate the software name. # diff --git a/spec/functional/builder_spec.rb b/spec/functional/builder_spec.rb index 7e761184e..5f096a430 100644 --- a/spec/functional/builder_spec.rb +++ b/spec/functional/builder_spec.rb @@ -436,5 +436,108 @@ def fake_embedded_bin(name) expect(File.symlink?("#{destination}/file_b")).to be_truthy end end + + describe '#sync' do + let(:source) do + source = File.join(tmp_path, 'source') + FileUtils.mkdir_p(source) + + FileUtils.touch(File.join(source, 'file_a')) + FileUtils.touch(File.join(source, 'file_b')) + FileUtils.touch(File.join(source, 'file_c')) + + FileUtils.mkdir_p(File.join(source, 'folder')) + FileUtils.touch(File.join(source, 'folder', 'file_d')) + FileUtils.touch(File.join(source, 'folder', 'file_e')) + + FileUtils.mkdir_p(File.join(source, '.dot_folder')) + FileUtils.touch(File.join(source, '.dot_folder', 'file_f')) + + FileUtils.touch(File.join(source, '.file_g')) + source + end + + let(:destination) { File.join(tmp_path, 'destination') } + + context 'when the destination is empty' do + it 'syncs the directories' do + subject.sync(source, destination) + subject.build + + expect(File.file?("#{destination}/file_a")).to be_truthy + expect(File.file?("#{destination}/file_b")).to be_truthy + expect(File.file?("#{destination}/file_c")).to be_truthy + expect(File.file?("#{destination}/folder/file_d")).to be_truthy + expect(File.file?("#{destination}/folder/file_e")).to be_truthy + expect(File.file?("#{destination}/.dot_folder/file_f")).to be_truthy + expect(File.file?("#{destination}/.file_g")).to be_truthy + end + end + + context 'when the directory exists' do + before { FileUtils.mkdir_p(destination) } + + it 'deletes existing files and folders' do + FileUtils.mkdir_p("#{destination}/existing_folder") + FileUtils.mkdir_p("#{destination}/.existing_folder") + FileUtils.touch("#{destination}/existing_file") + FileUtils.touch("#{destination}/.existing_file") + + subject.sync(source, destination) + subject.build + + expect(File.file?("#{destination}/file_a")).to be_truthy + expect(File.file?("#{destination}/file_b")).to be_truthy + expect(File.file?("#{destination}/file_c")).to be_truthy + expect(File.file?("#{destination}/folder/file_d")).to be_truthy + expect(File.file?("#{destination}/folder/file_e")).to be_truthy + expect(File.file?("#{destination}/.dot_folder/file_f")).to be_truthy + expect(File.file?("#{destination}/.file_g")).to be_truthy + + expect(File.exist?("#{destination}/existing_folder")).to be_falsey + expect(File.exist?("#{destination}/.existing_folder")).to be_falsey + expect(File.exist?("#{destination}/existing_file")).to be_falsey + expect(File.exist?("#{destination}/.existing_file")).to be_falsey + end + end + + context 'when :exclude is given' do + it 'does not copy files and folders that match the pattern' do + subject.sync(source, destination, exclude: '.dot_folder') + subject.build + + expect(File.file?("#{destination}/file_a")).to be_truthy + expect(File.file?("#{destination}/file_b")).to be_truthy + expect(File.file?("#{destination}/file_c")).to be_truthy + expect(File.file?("#{destination}/folder/file_d")).to be_truthy + expect(File.file?("#{destination}/folder/file_e")).to be_truthy + expect(File.exist?("#{destination}/.dot_folder")).to be_falsey + expect(File.file?("#{destination}/.dot_folder/file_f")).to be_falsey + expect(File.file?("#{destination}/.file_g")).to be_truthy + end + + it 'removes existing files and folders in destination' do + FileUtils.mkdir_p("#{destination}/existing_folder") + FileUtils.touch("#{destination}/existing_file") + FileUtils.mkdir_p("#{destination}/.dot_folder") + FileUtils.touch("#{destination}/.dot_folder/file_f") + + subject.sync(source, destination, exclude: '.dot_folder') + subject.build + + expect(File.file?("#{destination}/file_a")).to be_truthy + expect(File.file?("#{destination}/file_b")).to be_truthy + expect(File.file?("#{destination}/file_c")).to be_truthy + expect(File.file?("#{destination}/folder/file_d")).to be_truthy + expect(File.file?("#{destination}/folder/file_e")).to be_truthy + expect(File.exist?("#{destination}/.dot_folder")).to be_falsey + expect(File.file?("#{destination}/.dot_folder/file_f")).to be_falsey + expect(File.file?("#{destination}/.file_g")).to be_truthy + + expect(File.exist?("#{destination}/existing_folder")).to be_falsey + expect(File.exist?("#{destination}/existing_file")).to be_falsey + end + end + end end end diff --git a/spec/unit/builder_spec.rb b/spec/unit/builder_spec.rb index ff0b9f9d7..7b334184c 100644 --- a/spec/unit/builder_spec.rb +++ b/spec/unit/builder_spec.rb @@ -39,6 +39,7 @@ module Omnibus it_behaves_like 'a cleanroom setter', :copy, %|copy 'file', 'file2'| it_behaves_like 'a cleanroom setter', :move, %|move 'file', 'file2'| it_behaves_like 'a cleanroom setter', :link, %|link 'file', 'file2'| + it_behaves_like 'a cleanroom setter', :sync, %|link 'a/', 'b/'| it_behaves_like 'a cleanroom getter', :project_root, %|puts project_root| it_behaves_like 'a cleanroom getter', :windows_safe_path, %|puts windows_safe_path('foo')|