├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── rsync.rb └── rsync │ ├── change.rb │ ├── command.rb │ ├── configure.rb │ ├── result.rb │ └── version.rb ├── rsync.gemspec └── spec ├── rsync ├── change_spec.rb ├── command_spec.rb └── result_spec.rb ├── rsync_spec.rb ├── spec_helper.rb └── support ├── rsync_builder.rb └── tempdir.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | test.rb 19 | test.txt 20 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format doc --color 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | script: 'rake spec' 3 | matrix: 4 | fast_finish: true 5 | include: 6 | # - rvm: 1.9.3 7 | # env: RSYNC_VERSION="2.6.9" 8 | - rvm: 1.9.3 9 | env: RSYNC_VERSION="3.0.9" 10 | - rvm: 1.9.3 11 | env: RSYNC_VERSION="3.1.1" 12 | 13 | # - rvm: 2.0.0 14 | # env: RSYNC_VERSION="2.6.9" 15 | - rvm: 2.0.0 16 | env: RSYNC_VERSION="3.0.9" 17 | - rvm: 2.0.0 18 | env: RSYNC_VERSION="3.1.1" 19 | 20 | # - rvm: 2.1.2 21 | # env: RSYNC_VERSION="2.6.9" 22 | - rvm: 2.1.2 23 | env: RSYNC_VERSION="3.0.9" 24 | - rvm: 2.1.2 25 | env: RSYNC_VERSION="3.1.1" 26 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in rsync.gemspec 4 | gemspec 5 | 6 | gem 'coveralls', require: false 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Joshua Bussdieker 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rsync 2 | 3 | [![Build Status](https://travis-ci.org/jbussdieker/ruby-rsync.png?branch=master)](https://travis-ci.org/jbussdieker/ruby-rsync) 4 | [![Code Climate](https://codeclimate.com/github/jbussdieker/ruby-rsync.png)](https://codeclimate.com/github/jbussdieker/ruby-rsync) 5 | [![Gem Version](https://badge.fury.io/rb/rsync.png)](http://badge.fury.io/rb/rsync) 6 | [![Coverage Status](https://coveralls.io/repos/jbussdieker/ruby-rsync/badge.png)](https://coveralls.io/r/jbussdieker/ruby-rsync) 7 | [![Dependency Status](https://gemnasium.com/jbussdieker/ruby-rsync.svg)](https://gemnasium.com/jbussdieker/ruby-rsync) 8 | 9 | Ruby/Rsync is a Ruby library that can syncronize files between remote hosts by wrapping a call to the rsync binary. 10 | 11 | ## Usage 12 | 13 | Minimal example 14 | ```ruby 15 | require "rsync" 16 | 17 | result = Rsync.run("/path/to/src", "/path/to/dest") 18 | ``` 19 | 20 | Complete example 21 | ```ruby 22 | require "rsync" 23 | 24 | Rsync.run("/path/to/src", "/path/to/dest") do |result| 25 | if result.success? 26 | result.changes.each do |change| 27 | puts "#{change.filename} (#{change.summary})" 28 | end 29 | else 30 | puts result.error 31 | end 32 | end 33 | ``` 34 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | -------------------------------------------------------------------------------- /lib/rsync.rb: -------------------------------------------------------------------------------- 1 | require "rsync/version" 2 | require "rsync/command" 3 | require "rsync/result" 4 | require 'rsync/configure' 5 | 6 | # The main interface to rsync 7 | module Rsync 8 | extend Configure 9 | # Creates and runs an rsync {Command} and return the {Result} 10 | # @param source {String} 11 | # @param destination {String} 12 | # @param args {Array} 13 | # @return {Result} 14 | # @yield {Result} 15 | def self.run(source, destination, args = [], &block) 16 | destination = "#{self.host}:#{destination}" if self.host 17 | result = Command.run(source, destination, args) 18 | yield(result) if block_given? 19 | result 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rsync/change.rb: -------------------------------------------------------------------------------- 1 | module Rsync 2 | # Provides details about changes made to a specific file. 3 | # 4 | # Change Flags: 5 | # 6 | # :no_change 7 | # :identical 8 | # :new 9 | # :unknown 10 | # :changed 11 | class Change 12 | def initialize(data) 13 | @data = data 14 | end 15 | 16 | # The filename associated with this change. 17 | # @return [String] 18 | def filename 19 | @data[12..-1] 20 | end 21 | 22 | # Whether the file was changed or not. 23 | # @return [Boolean] 24 | def changed? 25 | if update_type == :no_change 26 | false 27 | else 28 | true 29 | end 30 | end 31 | 32 | # Simple description of the change. 33 | # @return [String] 34 | def summary 35 | if update_type == :message 36 | message 37 | elsif update_type == :recv and @data[2,9] == "+++++++++" 38 | "creating local" 39 | elsif update_type == :recv 40 | "updating local" 41 | elsif update_type == :sent and @data[2,9] == "+++++++++" 42 | "creating remote" 43 | elsif update_type == :sent 44 | "updating remote" 45 | else 46 | changes = [] 47 | [:checksum, :size, :timestamp, :permissions, :owner, :group, :acl].each do |prop| 48 | changes << prop if send(prop) == :changed 49 | end 50 | changes.join(", ") 51 | end 52 | end 53 | 54 | # @!group Change Flags 55 | 56 | # The change, if any, to the checksum of the file. 57 | # @return [Symbol] 58 | def checksum 59 | attribute_prop(2) 60 | end 61 | 62 | # The change, if any, to the size of the file. 63 | # @return [Symbol] 64 | def size 65 | attribute_prop(3) 66 | end 67 | 68 | # The change, if any, to the timestamp of the file. 69 | # @return [Symbol] 70 | def timestamp 71 | attribute_prop(4) 72 | end 73 | 74 | # The change, if any, to the file permissions. 75 | # @return [Symbol] 76 | def permissions 77 | attribute_prop(5) 78 | end 79 | 80 | # The change, if any, to the owner of the file. 81 | # @return [Symbol] 82 | def owner 83 | attribute_prop(6) 84 | end 85 | 86 | # The change, if any, to the group of the file. 87 | # @return [Symbol] 88 | def group 89 | attribute_prop(7) 90 | end 91 | 92 | # The change, if any, to the file ACL. 93 | # @return [Symbol] 94 | def acl 95 | attribute_prop(9) 96 | end 97 | 98 | # The change, if any, to the file's extended attributes. 99 | # @return [Symbol] 100 | def ext_attr 101 | attribute_prop(10) 102 | end 103 | 104 | # @!endgroup 105 | 106 | # The type of update made to the file. 107 | # 108 | # :sent 109 | # :recv 110 | # :change 111 | # :hard_link 112 | # :no_update 113 | # :message 114 | # 115 | # @return [Symbol] 116 | def update_type 117 | case raw_update_type 118 | when '<' 119 | :sent 120 | when '>' 121 | :recv 122 | when 'c' 123 | :change 124 | when 'h' 125 | :hard_link 126 | when '.' 127 | :no_update 128 | when '*' 129 | :message 130 | end 131 | end 132 | 133 | # The type of file. 134 | # 135 | # :file 136 | # :directory 137 | # :symlink 138 | # :device 139 | # :special 140 | # 141 | # @return [Symbol] 142 | def file_type 143 | case raw_file_type 144 | when 'f' 145 | :file 146 | when 'd' 147 | :directory 148 | when 'L' 149 | :symlink 150 | when 'D' 151 | :device 152 | when 'S' 153 | :special 154 | end 155 | end 156 | 157 | private 158 | 159 | def message 160 | @data[1..10].strip 161 | end 162 | 163 | def raw_update_type 164 | @data[0,1] 165 | end 166 | 167 | def raw_file_type 168 | @data[1,1] 169 | end 170 | 171 | def attribute_prop(index) 172 | case @data[index,1] 173 | when '.' 174 | :no_change 175 | when ' ' 176 | :identical 177 | when '+' 178 | :new 179 | when '?' 180 | :unknown 181 | else 182 | :changed 183 | end 184 | end 185 | 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /lib/rsync/command.rb: -------------------------------------------------------------------------------- 1 | require 'rsync/result' 2 | require 'shellwords' 3 | 4 | module Rsync 5 | # An rsync command to be run 6 | class Command 7 | # Runs the rsync job and returns the results 8 | # 9 | # @param args {Array} 10 | # @return {Result} 11 | def self.run(*args) 12 | output = run_command([command, "--itemize-changes", args].flatten.shelljoin) 13 | Result.new(output, $?.exitstatus) 14 | end 15 | 16 | def self.command 17 | @command ||= "rsync" 18 | end 19 | 20 | def self.command=(cmd) 21 | @command = cmd 22 | end 23 | 24 | private 25 | 26 | def self.run_command(cmd, &block) 27 | if block_given? 28 | IO.popen("#{cmd} 2>&1", &block) 29 | else 30 | `#{cmd} 2>&1` 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/rsync/configure.rb: -------------------------------------------------------------------------------- 1 | module Rsync 2 | module Configure 3 | VALID_OPTION_KEYS = [ 4 | :host 5 | ].freeze 6 | 7 | attr_accessor *VALID_OPTION_KEYS 8 | 9 | def configure 10 | yield self 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/rsync/result.rb: -------------------------------------------------------------------------------- 1 | require 'rsync/change' 2 | 3 | module Rsync 4 | # The result of a sync. 5 | class Result 6 | # Exit code returned by rsync 7 | attr_accessor :exitcode 8 | 9 | # Error messages by exit code 10 | ERROR_CODES = { 11 | "0" => "Success", 12 | "1" => "Syntax or usage error", 13 | "2" => "Protocol incompatibility", 14 | "3" => "Errors selecting input/output files, dirs", 15 | "4" => "Requested action not supported: an attempt was made to manipulate 64-bit files on a platform that can not support them; or an option was specified that is supported by the client and not by the server.", 16 | "5" => "Error starting client-server protocol", 17 | "6" => "Daemon unable to append to log-file", 18 | "10" => "Error in socket I/O", 19 | "11" => "Error in file I/O", 20 | "12" => "Error in rsync protocol data stream", 21 | "13" => "Errors with program diagnostics", 22 | "14" => "Error in IPC code", 23 | "20" => "Received SIGUSR1 or SIGINT", 24 | "21" => "Some error returned by waitpid()", 25 | "22" => "Error allocating core memory buffers", 26 | "23" => "Partial transfer due to error", 27 | "24" => "Partial transfer due to vanished source files", 28 | "25" => "The --max-delete limit stopped deletions", 29 | "30" => "Timeout in data send/receive", 30 | "35" => "Timeout waiting for daemon connection" 31 | } 32 | 33 | # @!visibility private 34 | def initialize(raw, exitcode) 35 | @raw = raw 36 | @exitcode = exitcode 37 | end 38 | 39 | # Whether the rsync job was run without errors. 40 | # @return {Boolean} 41 | def success? 42 | @exitcode == 0 43 | end 44 | 45 | # The error message based on exit code. 46 | # @return {String} 47 | def error 48 | error_key = @exitcode.to_s 49 | if ERROR_CODES.has_key? error_key 50 | ERROR_CODES[error_key] 51 | elsif @raw =~ /Permission denied \(publickey\)/ 52 | "Permission denied (publickey)" 53 | else 54 | "Unknown Error" 55 | end 56 | end 57 | 58 | # List of changes made during this run. 59 | # 60 | # @return {Array} 61 | def changes 62 | change_list 63 | end 64 | 65 | private 66 | 67 | def change_list 68 | list = [] 69 | @raw.split("\n").each do |line| 70 | #if line =~ /^([<>ch.*][fdLDS][ .+\?cstTpoguax]{9}) (.*)$/ 71 | if line =~ /^([<>ch+\.\*].{10}) (.*)$/ 72 | detail = Change.new(line) 73 | list << detail if detail.changed? 74 | end 75 | end 76 | list 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/rsync/version.rb: -------------------------------------------------------------------------------- 1 | module Rsync 2 | # Project version 3 | VERSION = "1.0.9" 4 | end 5 | -------------------------------------------------------------------------------- /rsync.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'rsync/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "rsync" 8 | spec.version = Rsync::VERSION 9 | spec.authors = ["Joshua Bussdieker"] 10 | spec.email = ["jbussdieker@gmail.com"] 11 | spec.summary = %q{Ruby/Rsync is a Ruby library that can syncronize files between remote hosts by wrapping a call to the rsync binary.} 12 | spec.homepage = "http://github.com/jbussdieker/ruby-rsync" 13 | spec.license = "MIT" 14 | 15 | spec.files = `git ls-files`.split($/) 16 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 17 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 18 | spec.require_paths = ["lib"] 19 | 20 | spec.add_development_dependency "bundler", "~> 1.3" 21 | spec.add_development_dependency "rake" 22 | spec.add_development_dependency "rspec" 23 | end 24 | -------------------------------------------------------------------------------- /spec/rsync/change_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rsync/change' 2 | 3 | describe Rsync::Change do 4 | it "should handle filename" do 5 | expect(Rsync::Change.new(" filename").filename).to eql("filename") 6 | end 7 | 8 | it "should handle message type" do 9 | expect(Rsync::Change.new("*deleting ").summary).to eql("deleting") 10 | end 11 | 12 | it "should handle update types" do 13 | expect(Rsync::Change.new("< ").update_type).to eql(:sent) 14 | expect(Rsync::Change.new("> ").update_type).to eql(:recv) 15 | expect(Rsync::Change.new("c ").update_type).to eql(:change) 16 | expect(Rsync::Change.new("h ").update_type).to eql(:hard_link) 17 | expect(Rsync::Change.new(". ").update_type).to eql(:no_update) 18 | expect(Rsync::Change.new("* ").update_type).to eql(:message) 19 | end 20 | 21 | it "should handle file types" do 22 | expect(Rsync::Change.new(" f ").file_type).to eql(:file) 23 | expect(Rsync::Change.new(" d ").file_type).to eql(:directory) 24 | expect(Rsync::Change.new(" L ").file_type).to eql(:symlink) 25 | expect(Rsync::Change.new(" D ").file_type).to eql(:device) 26 | expect(Rsync::Change.new(" S ").file_type).to eql(:special) 27 | end 28 | 29 | it "should handle checksum info" do 30 | expect(Rsync::Change.new(" c ").checksum).to eql(:changed) 31 | expect(Rsync::Change.new(" . ").checksum).to eql(:no_change) 32 | expect(Rsync::Change.new(" ").checksum).to eql(:identical) 33 | expect(Rsync::Change.new(" + ").checksum).to eql(:new) 34 | expect(Rsync::Change.new(" ? ").checksum).to eql(:unknown) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/rsync/command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rsync/command' 2 | 3 | describe Rsync::Command do 4 | it "should work" do 5 | expect(Rsync::Command.run("/path/to/src/", "/path/to/dest", "-a")).to be_kind_of(::Rsync::Result) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/rsync/result_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rsync/result' 2 | 3 | describe Rsync::Result do 4 | it "should handle basic example" do 5 | result = Rsync::Result.new("", 0) 6 | expect(result.changes).to eql([]) 7 | expect(result.error).to eql("Success") 8 | expect(result.success?).to eql(true) 9 | expect(result.exitcode).to eql(0) 10 | end 11 | 12 | it "should handle basic example with changes" do 13 | result = Rsync::Result.new(">f......... filename\n", 0) 14 | expect(result.changes.length).to eql(1) 15 | expect(result.error).to eql("Success") 16 | expect(result.success?).to eql(true) 17 | expect(result.exitcode).to eql(0) 18 | end 19 | 20 | it "should handle syntax error" do 21 | result = Rsync::Result.new("", 1) 22 | expect(result.changes).to eql([]) 23 | expect(result.error).to eql("Syntax or usage error") 24 | expect(result.success?).to eql(false) 25 | expect(result.exitcode).to eql(1) 26 | end 27 | 28 | it "should handle ssh error" do 29 | result = Rsync::Result.new("Permission denied (publickey)", 255) 30 | expect(result.changes).to eql([]) 31 | expect(result.error).to eql("Permission denied (publickey)") 32 | expect(result.success?).to eql(false) 33 | expect(result.exitcode).to eql(255) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/rsync_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'rsync' 3 | 4 | describe Rsync do 5 | 6 | context 'with configurations' do 7 | before do 8 | Rsync.configure do |config| 9 | config.host = 'root@127.0.0.1' 10 | end 11 | end 12 | 13 | it "should respond to host" do 14 | expect(Rsync).to respond_to(:host) 15 | expect(Rsync.host).to eql('root@127.0.0.1') 16 | end 17 | 18 | describe "run" do 19 | it "prepend the host to the destination" do 20 | allow(Rsync::Command).to receive(:run) 21 | expect(Rsync::Command).to receive(:run).with('/foo1', 'root@127.0.0.1:/foo2', ["-a"]) 22 | Rsync.run('/foo1', '/foo2', ["-a"]) 23 | end 24 | end 25 | end 26 | 27 | around(:each) do |example| 28 | TempDir.create do |src, dest| 29 | @src = src 30 | @dest = dest 31 | example.run 32 | end 33 | end 34 | 35 | it "should work" do 36 | @src.mkdir("blah") 37 | Rsync.run(@src.path + "/", @dest.path, ["-a"]) 38 | expect(@dest).to eql(@src) 39 | end 40 | 41 | it "should dry run" do 42 | @src.mkdir("blah") 43 | Rsync.run(@src.path + "/", @dest.path, ["-a", "-n"]) 44 | expect(@dest).to_not eql(@src) 45 | end 46 | 47 | it "should list changes" do 48 | @src.mkdir("blah") 49 | result = Rsync.run(@src.path + "/", @dest.path, ["-a"]) 50 | expect(result).to be_success 51 | expect(result.changes.length).to eql(1) 52 | expect(@dest).to eql(@src) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear! 3 | 4 | # Requires supporting ruby files with custom matchers and macros, etc, 5 | # in spec/support/ and its subdirectories. 6 | root = File.expand_path("./..", __FILE__) 7 | 8 | Dir[File.join(root, "support/**/*.rb")].each { |f| require f } 9 | 10 | RSpec.configure do |spec| 11 | spec.before(:suite) do 12 | if ENV["RSYNC_VERSION"] 13 | version = ENV["RSYNC_VERSION"] || "2.6.9" 14 | 15 | builder = RsyncBuilder.new(version) 16 | cmd = builder.build 17 | 18 | puts `#{cmd} --version` 19 | Rsync::Command.command = cmd 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/rsync_builder.rb: -------------------------------------------------------------------------------- 1 | class RsyncBuilder 2 | attr_accessor :version 3 | 4 | def initialize(version) 5 | @version = version 6 | 7 | unless File.exists? "tmp" 8 | puts `mkdir -p tmp` 9 | end 10 | end 11 | 12 | def download 13 | unless File.exists? "tmp/rsync-#{version}.tar.gz" 14 | puts `cd tmp && wget https://download.samba.org/pub/rsync/src/rsync-#{version}.tar.gz` 15 | end 16 | end 17 | 18 | def extract 19 | unless File.exists? "tmp/rsync-#{version}" 20 | puts `cd tmp && tar zxvf rsync-#{version}.tar.gz` 21 | end 22 | end 23 | 24 | def configure 25 | unless File.exists? "tmp/rsync-#{version}/Makefile" 26 | puts `cd tmp/rsync-#{version} && ./configure` 27 | end 28 | end 29 | 30 | def compile 31 | unless File.exists? "tmp/rsync-#{version}/rsync" 32 | puts `cd tmp/rsync-#{version} && make` 33 | end 34 | end 35 | 36 | def build 37 | download 38 | extract 39 | configure 40 | compile 41 | "tmp/rsync-#{version}/rsync" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/support/tempdir.rb: -------------------------------------------------------------------------------- 1 | require 'tmpdir' 2 | 3 | class TempDir 4 | attr_accessor :path 5 | 6 | def initialize(root, subpath) 7 | @path = File.join(root, subpath) 8 | Dir.mkdir(@path) 9 | end 10 | 11 | def tree 12 | `which tree` 13 | if $?.to_i == 0 14 | `cd #{@path}; tree -pugAD` 15 | else 16 | #`cd #{@path}; find . -printf "%A@ %p\n"` 17 | `cd #{@path}; find . -printf "%p\n"` 18 | end 19 | end 20 | 21 | def mkdir(path) 22 | Dir.mkdir(File.join(@path, path)) 23 | end 24 | 25 | def eql? other 26 | tree == other.tree 27 | end 28 | 29 | def to_s 30 | tree 31 | end 32 | 33 | def self.create(&block) 34 | Dir.mktmpdir do |dir| 35 | yield new(dir, "src"), new(dir, "dest") 36 | end 37 | end 38 | end 39 | --------------------------------------------------------------------------------