dir_sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []