dir_sync 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gemtest ADDED
File without changes
data/HISTORY.rdoc ADDED
@@ -0,0 +1,4 @@
1
+ = 0.1.0
2
+
3
+ Fri 27 Jan 2012 20:57:18 EST
4
+ Initial release
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Mark Ryall
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,29 @@
1
+ = dir_sync
2
+
3
+ Produce a script to synchronise two directories. Kind of like unison but a bit simpler.
4
+
5
+ = Usage
6
+
7
+ dir_sync name folder1 folder2 ...
8
+
9
+ This will output all require mkdir and cp commands that would be required to sync the
10
+ folders. It will also produce the index file (~/.dir_sync/NAME) which will be used
11
+ in subsequent synchronisations to detect deletions. You can then review the commands
12
+ before executing them (in case they seem likely to do something undesirable). Note that
13
+ files that differ only in modification time will be overwritten with the most recent one.
14
+
15
+ drain script
16
+
17
+ This simply runs each line in the specified script. As each line is executed, a new script is
18
+ produced. The purpose of this is to allow the script to be interrupted and able to resume.
19
+
20
+ Together, these scripts can be used as follows:
21
+
22
+ dir_sync photos /Volumes/BACKUP/sync/photos /Users/Shared/Photos | tee sync.sh
23
+ drain sync.sh
24
+
25
+ = Future Plans
26
+
27
+ * actually execute file commands perhaps with optional confirmation
28
+ * consider introducing checksums/filesize comparisons to avoid copying identical files that
29
+ differ only in modification time.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ require 'bundler/gem_tasks'
2
+
3
+ task :default => :test
4
+ task :test => [:spec, :features]
5
+
6
+ task :spec do
7
+ sh 'rspec spec'
8
+ end
9
+
10
+ task :features do
11
+ sh 'cucumber'
12
+ end
data/bin/dir_sync ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $: << File.dirname(__FILE__)+'/../lib'
4
+
5
+ require 'synchroniser'
6
+
7
+ if ARGV.size < 3
8
+ puts "usage: #{__FILE__} name directory1 directory2 ..."
9
+ puts " set DEBUG for verbose output"
10
+ exit 1
11
+ end
12
+
13
+ Synchroniser.iterate *ARGV
data/bin/drain ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ if ARGV.empty?
4
+ puts "Runs a set of single line commands one by one (so that you can interrupt and the script will resume)"
5
+ puts "usage: #{__FILE__} script"
6
+ exit 1
7
+ end
8
+
9
+ file = ARGV.shift
10
+ lines = File.readlines(file).map {|s| s.chomp.strip }.select {|s| !s.empty? and !s.start_with?('#')}
11
+
12
+ loop do
13
+ break if lines.empty?
14
+ lines.shift.tap {|s| puts "> #{s}" }.tap {|s| puts `#{s}` }
15
+ File.open("#{file}.tmp", 'w') {|f| f.puts lines.join "\n" }
16
+ `mv #{file}.tmp #{file}`
17
+ end
18
+ `rm #{file}`
@@ -0,0 +1,15 @@
1
+ class ChangeLogFileSystem
2
+ def initialize io
3
+ @io = io
4
+ end
5
+
6
+ def cp from, to
7
+ to_dir = File.dirname to
8
+ @io.puts "mkdir -p \"#{to_dir}\"" unless File.exist? to_dir
9
+ @io.puts "cp -p \"#{from}\" \"#{to}\""
10
+ end
11
+
12
+ def rm path
13
+ @io.puts "rm \"#{path}\""
14
+ end
15
+ end
@@ -0,0 +1,74 @@
1
+ class ChangeResolver
2
+ def debug message
3
+ puts "# #{message}" if ENV['DEBUG']
4
+ end
5
+
6
+ def initialize history, *traversers
7
+ @history, @traversers = history, traversers
8
+ end
9
+
10
+ def iterate
11
+ report_traversers
12
+ first = candidates.first
13
+ return false unless first
14
+ debug "first is #{first.description}"
15
+ send dispatch(first), first
16
+ advance_matching_traversers first.name, @history, *non_empty_traversers
17
+ end
18
+
19
+ def candidate
20
+ candidates.first
21
+ end
22
+ private
23
+ def dispatch traverser
24
+ return :rm if traverser.ignored?
25
+ if traverser.equivalent? @history
26
+ return all_equivalent_traversers?(traverser) ? :ignore : :rm
27
+ end
28
+ :cp
29
+ end
30
+
31
+ def rm traverser
32
+ traverser.rm
33
+ end
34
+
35
+ def cp traverser
36
+ @history.report traverser
37
+ traverser.cp *@traversers
38
+ end
39
+
40
+ def ignore traverser
41
+ end
42
+
43
+ def report_traversers
44
+ report @history
45
+ @traversers.each { |traverser| report traverser }
46
+ end
47
+
48
+ def report traverser
49
+ debug "#{traverser.base}: #{traverser.description}"
50
+ end
51
+
52
+ def advance_matching_traversers name, *traversers
53
+ traversers.select{|t| t.name == name}.each &:advance
54
+ end
55
+
56
+ def all_equivalent_traversers? traverser
57
+ @traversers.all? {|t| traverser.equivalent? t }
58
+ end
59
+
60
+ def non_empty_traversers
61
+ @traversers.select {|t| !t.empty? }
62
+ end
63
+
64
+ def candidates
65
+ non_empty_traversers.sort do |left,right|
66
+ combine left.name <=> right.name, right.ts <=> left.ts
67
+ end
68
+ end
69
+
70
+ def combine *exps
71
+ exps.each { |exp| return exp unless exp == 0 }
72
+ 0
73
+ end
74
+ end
@@ -0,0 +1,46 @@
1
+ class HistoricalTraverser
2
+ attr_reader :name, :ts, :base, :description
3
+ REGEXP = /:(\d+)$/
4
+
5
+ def initialize name
6
+ home = File.expand_path '~'
7
+ Pathname.new("#{home}/.dir_sync").mkpath
8
+ @path_old = "#{home}/.dir_sync/#{name}"
9
+ @path_new = "#{@path_old}.new"
10
+ @new = File.open @path_new, 'w'
11
+ @base = '<history>'
12
+ if File.exist? @path_old
13
+ @fiber = Fiber.new do
14
+ File.open(@path_old) do |file|
15
+ file.each {|line| Fiber.yield line.chomp}
16
+ end
17
+ Fiber.yield nil
18
+ end
19
+ @description = :nothing
20
+ advance
21
+ end
22
+ end
23
+
24
+ def advance
25
+ @description = @fiber.resume if @description
26
+ if @description
27
+ match = REGEXP.match @description
28
+ raise "unable to parse line \"#{@description}\"" unless match
29
+ @name = match.pre_match
30
+ @ts = match[1].to_i
31
+ end
32
+ end
33
+
34
+ def close
35
+ @new.close
36
+ `mv #{@path_new} #{@path_old}`
37
+ end
38
+
39
+ def report traverser
40
+ @new.puts traverser.description
41
+ end
42
+
43
+ def empty?
44
+ @description.nil?
45
+ end
46
+ end
@@ -0,0 +1,15 @@
1
+ require 'traverser'
2
+ require 'historical_traverser'
3
+ require 'change_resolver'
4
+ require 'change_log_file_system'
5
+
6
+ module Synchroniser
7
+ def self.iterate name, *paths
8
+ file_system = ChangeLogFileSystem.new $stdout
9
+ traversers = paths.map {|path| Traverser.new path, file_system }
10
+ history = HistoricalTraverser.new name
11
+ resolver = ChangeResolver.new history, *traversers
12
+ loop { break unless resolver.iterate }
13
+ history.close
14
+ end
15
+ end
data/lib/traverser.rb ADDED
@@ -0,0 +1,60 @@
1
+ require 'pathname'
2
+
3
+ class Traverser
4
+ attr_reader :base
5
+
6
+ TOLERANCE=5
7
+ IGNORE_PATTERNS = [
8
+ /\.DS_Store$/,
9
+ /\/\._/
10
+ ]
11
+
12
+ def initialize path, file_system
13
+ @file_system = file_system
14
+ @base = Pathname.new path
15
+ @base.mkpath
16
+ @fiber = Fiber.new do
17
+ @base.find { |child| Fiber.yield child if child.file? }
18
+ Fiber.yield nil
19
+ end
20
+ @current = @fiber.resume
21
+ end
22
+
23
+ def description
24
+ empty? ? 'empty' : "#{name}:#{ts}"
25
+ end
26
+
27
+ def advance
28
+ @current = @fiber.resume if @current
29
+ end
30
+
31
+ def name
32
+ @current.relative_path_from(@base).to_s if @current
33
+ end
34
+
35
+ def ts
36
+ @current.mtime.to_i if @current
37
+ end
38
+
39
+ def empty?
40
+ @current.nil?
41
+ end
42
+
43
+ def ignored?
44
+ IGNORE_PATTERNS.any? {|pattern| pattern.match name }
45
+ end
46
+
47
+ def cp *traversers
48
+ traversers.each do |t|
49
+ @file_system.cp @current.to_s, "#{t.base}/#{name}" unless equivalent? t
50
+ end
51
+ end
52
+
53
+ def rm
54
+ @file_system.rm @current.to_s
55
+ end
56
+
57
+ def equivalent? traverser
58
+ name == traverser.name and (ts - traverser.ts).abs <= TOLERANCE
59
+ end
60
+ end
@@ -0,0 +1,165 @@
1
+ $: << File.dirname(__FILE__)+'/../lib'
2
+
3
+ require 'change_resolver'
4
+
5
+ describe ChangeResolver do
6
+ let(:history) { stub 'history', name: nil, base: nil, description: nil, report: nil, advance: nil }
7
+ let(:traversers) { [] }
8
+ let(:resolver) { ChangeResolver.new history, *traversers }
9
+
10
+ def stub_history hash
11
+ hash.each do |meth,ret|
12
+ history.stub!(meth).and_return ret
13
+ end
14
+ end
15
+
16
+ def stub_traversers hashes
17
+ hashes.each_with_index do |traverser_stubs, index|
18
+ stubs = {
19
+ ts: 0,
20
+ cp: nil,
21
+ advance: nil,
22
+ empty?: false,
23
+ ignored?: false,
24
+ base: nil,
25
+ description: nil,
26
+ equivalent?: false
27
+ }.merge traverser_stubs
28
+ traversers << stub("traverser#{index}").tap do |traverser|
29
+ stubs.each do |meth,ret|
30
+ traverser.stub!(meth).and_return ret
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def candidate
37
+ resolver.candidate
38
+ end
39
+
40
+ describe '#iterate' do
41
+ it 'should return true if any traverser is not empty' do
42
+ stub_traversers [
43
+ { name: 'a'},
44
+ { name: 'a'}
45
+ ]
46
+ resolver.iterate.should be_true
47
+ end
48
+
49
+ it 'should return false if all traversers are empty' do
50
+ stub_traversers [
51
+ { name: 'a'},
52
+ { name: 'b'}
53
+ ]
54
+ traversers[0].should_receive(:empty?).and_return true
55
+ traversers[1].should_receive(:empty?).and_return true
56
+ resolver.iterate.should be_false
57
+ end
58
+
59
+ it 'should copy all other traversers to candidate' do
60
+ stub_traversers [
61
+ { name: 'a'},
62
+ { name: 'b'},
63
+ { name: 'c'},
64
+ ]
65
+ traversers[0].should_receive(:cp).with *traversers
66
+ resolver.iterate
67
+ end
68
+
69
+ it 'should remove files that are ignored' do
70
+ stub_traversers [
71
+ { name: 'a', ignored?: true},
72
+ { name: 'b'},
73
+ { name: 'c'},
74
+ ]
75
+ traversers[0].should_receive :rm
76
+ resolver.iterate
77
+ end
78
+
79
+ it 'should ignore files that appear in history and have not been removed from any traverser' do
80
+ stub_history name: 'a'
81
+ stub_traversers [
82
+ { name: 'a'},
83
+ { name: 'a'}
84
+ ]
85
+ traversers[0].should_receive(:equivalent?).with(history).and_return true
86
+ traversers[0].should_receive(:equivalent?).with(traversers[0]).and_return true
87
+ traversers[0].should_receive(:equivalent?).with(traversers[1]).and_return true
88
+ traversers[0].should_not_receive :rm
89
+ resolver.iterate
90
+ end
91
+
92
+ it 'should remove files that appear in history but have been removed from a traverser' do
93
+ stub_history name: 'a'
94
+ stub_traversers [
95
+ { name: 'a'},
96
+ { name: 'b'},
97
+ { name: 'a'}
98
+ ]
99
+ traversers[0].should_receive(:equivalent?).with(history).and_return true
100
+ traversers[0].should_receive(:equivalent?).with(traversers[0]).and_return true
101
+ traversers[0].should_receive(:equivalent?).with(traversers[1]).and_return false
102
+ traversers[0].should_receive :rm
103
+ resolver.iterate
104
+ end
105
+
106
+ it 'should advance history when it has candidate name' do
107
+ stub_history name: 'a'
108
+ stub_traversers [{ name: 'a'}, { name: 'b'}]
109
+ history.should_receive :advance
110
+ resolver.iterate
111
+ end
112
+
113
+ it 'should advance traversers with the candidate name' do
114
+ stub_history name: 'b'
115
+ stub_traversers [
116
+ { name: 'a'},
117
+ { name: 'a'},
118
+ { name: 'b'},
119
+ ]
120
+ history.should_not_receive :advance
121
+ traversers[0].should_receive :advance
122
+ traversers[1].should_receive :advance
123
+ traversers[2].should_not_receive :advance
124
+ resolver.iterate
125
+ end
126
+ end
127
+
128
+ describe '#candidate' do
129
+ it 'should determine the next candidate according to the name collation order' do
130
+ stub_traversers [
131
+ { name: 'a'},
132
+ { name: 'b'},
133
+ { name: 'c'},
134
+ ]
135
+ candidate.should == traversers[0]
136
+ end
137
+
138
+ it 'should determine the next candidate according to the name collation order' do
139
+ stub_traversers [
140
+ { name: 'c'},
141
+ { name: 'b'},
142
+ { name: 'a'},
143
+ ]
144
+ candidate.should == traversers[2]
145
+ end
146
+
147
+ it 'should determine the next candidate for the same name by most recent timestamp' do
148
+ stub_traversers [
149
+ { name: 'a', ts: 10},
150
+ { name: 'a', ts: 20},
151
+ { name: 'b'},
152
+ ]
153
+ candidate.should == traversers[1]
154
+ end
155
+
156
+ it 'should determine the next candidate for the same name by most recent timestamp' do
157
+ stub_traversers [
158
+ { name: 'a', ts: 20},
159
+ { name: 'a', ts: 10},
160
+ { name: 'b'},
161
+ ]
162
+ candidate.should == traversers[0]
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,31 @@
1
+ $: << File.dirname(__FILE__)+'/../lib'
2
+
3
+ require 'synchroniser'
4
+
5
+ describe Synchroniser do
6
+ let(:file_system) { stub 'file_system' }
7
+ let(:resolver) { stub 'resolver'}
8
+ let(:history) { stub 'history', close: nil }
9
+ let(:traverser_a) { stub 'traverser_a' }
10
+ let(:traverser_b) { stub 'traverser_b' }
11
+
12
+ before do
13
+ ChangeLogFileSystem.should_receive(:new).with($stdout).and_return file_system
14
+ HistoricalTraverser.should_receive(:new).with('test').and_return history
15
+ Traverser.should_receive(:new).with('a', file_system).and_return traverser_a
16
+ Traverser.should_receive(:new).with('b', file_system).and_return traverser_b
17
+ ChangeResolver.should_receive(:new).with(history, traverser_a, traverser_b).and_return resolver
18
+ end
19
+
20
+ it 'should call iterate once resolved if it returns false' do
21
+ resolver.should_receive(:iterate).and_return false
22
+ Synchroniser.iterate 'test', 'a', 'b'
23
+ end
24
+
25
+ it 'should repeatedly call iterate on resolved until it returns false' do
26
+ resolver.should_receive(:iterate).and_return true
27
+ resolver.should_receive(:iterate).and_return true
28
+ resolver.should_receive(:iterate).and_return false
29
+ Synchroniser.iterate 'test', 'a', 'b'
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ $: << File.dirname(__FILE__)+'/../lib'
2
+
3
+ require 'traverser'
4
+
5
+ describe Traverser do
6
+ let(:pathname) { stub 'pathname', mkpath: nil, find: nil}
7
+ let(:file_system) { stub 'file_system'}
8
+ let(:traverser) { Traverser.new 'a', file_system }
9
+
10
+ before do
11
+ Pathname.should_receive(:new).with('a').and_return pathname
12
+ end
13
+
14
+ it 'should create path' do
15
+ pathname.should_receive :mkpath
16
+ traverser
17
+ end
18
+
19
+ def with_file name, mtime=0
20
+ child_pathname = stub 'current_pathname', file?: true, mtime: mtime, to_s: "a/#{name}"
21
+ child_pathname.stub!(:relative_path_from).with(pathname).and_return name
22
+ pathname.stub!(:find).and_yield child_pathname
23
+ end
24
+
25
+ it 'should skip copying to itself' do
26
+ with_file '1.txt'
27
+ file_system.should_not_receive :cp
28
+ traverser.cp traverser
29
+ end
30
+
31
+ it 'should copy to traversers with a different file name' do
32
+ with_file '1.txt'
33
+ other_traverser = stub 'other_traverser', base: 'b', name: '2.txt'
34
+ file_system.should_receive(:cp).with 'a/1.txt', 'b/1.txt'
35
+ traverser.cp other_traverser
36
+ end
37
+
38
+ it 'should skip copying to traversers with the same file name and timestamp' do
39
+ with_file '1.txt', 10
40
+ other_traverser = stub 'other_traverser', base: 'b', name: '1.txt', ts: 10
41
+ file_system.should_not_receive :cp
42
+ traverser.cp other_traverser
43
+ end
44
+
45
+ it 'should skip copying to traversers with the same file name and timestamp is within tolerance' do
46
+ with_file '1.txt', 10
47
+ other_traverser = stub 'other_traverser', base: 'b', name: '1.txt', ts: 15
48
+ file_system.should_not_receive :cp
49
+ traverser.cp other_traverser
50
+ end
51
+
52
+ it 'should skip copying to traversers with the same file name and timestamp is outside tolerance' do
53
+ with_file '1.txt', 10
54
+ other_traverser = stub 'other_traverser', base: 'b', name: '1.txt', ts: 16
55
+ file_system.should_receive(:cp).with 'a/1.txt', 'b/1.txt'
56
+ traverser.cp other_traverser
57
+ end
58
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dir_sync
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Mark Ryall
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-01-27 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rake
16
+ requirement: &70177781836300 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *70177781836300
25
+ - !ruby/object:Gem::Dependency
26
+ name: rspec
27
+ requirement: &70177781835000 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: '2'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *70177781835000
36
+ - !ruby/object:Gem::Dependency
37
+ name: guard
38
+ requirement: &70177781834120 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: *70177781834120
47
+ - !ruby/object:Gem::Dependency
48
+ name: guard-rspec
49
+ requirement: &70177781832600 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :development
56
+ prerelease: false
57
+ version_requirements: *70177781832600
58
+ - !ruby/object:Gem::Dependency
59
+ name: aruba
60
+ requirement: &70177781831880 !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ! '>='
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ type: :development
67
+ prerelease: false
68
+ version_requirements: *70177781831880
69
+ description: ! 'Bidirectional directory synchronisation for any number of directories
70
+
71
+ '
72
+ email: mark@ryall.name
73
+ executables:
74
+ - dir_sync
75
+ - drain
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - lib/change_log_file_system.rb
80
+ - lib/change_resolver.rb
81
+ - lib/historical_traverser.rb
82
+ - lib/synchroniser.rb
83
+ - lib/traverser.rb
84
+ - spec/change_resolver_spec.rb
85
+ - spec/synchroniser_spec.rb
86
+ - spec/traverser_spec.rb
87
+ - bin/dir_sync
88
+ - bin/drain
89
+ - README.rdoc
90
+ - MIT-LICENSE
91
+ - HISTORY.rdoc
92
+ - Rakefile
93
+ - .gemtest
94
+ homepage: http://github.com/markryall/dir_sync
95
+ licenses: []
96
+ post_install_message:
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ none: false
102
+ requirements:
103
+ - - ! '>='
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ segments:
107
+ - 0
108
+ hash: -2227443201844382087
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ none: false
111
+ requirements:
112
+ - - ! '>='
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ segments:
116
+ - 0
117
+ hash: -2227443201844382087
118
+ requirements: []
119
+ rubyforge_project:
120
+ rubygems_version: 1.8.10
121
+ signing_key:
122
+ specification_version: 3
123
+ summary: directory synchroniser
124
+ test_files: []