synchrotron 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/bin/synchrotron ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'trollop'
5
+
6
+ require 'synchrotron'
7
+
8
+ module Synchrotron
9
+ config = {}
10
+
11
+ # Parse command-line options.
12
+ options = Trollop.options do
13
+ version "#{APP_NAME} #{APP_VERSION}\n" << APP_COPYRIGHT
14
+ banner <<-BANNER
15
+ Synchrotron monitors a local directory tree and performs nearly instantaneous
16
+ one-way synchronization of changes to a remote directory.
17
+
18
+ Usage:
19
+ synchrotron <remote dir> [local dir]
20
+ BANNER
21
+
22
+ text "\nOptions:"
23
+ opt :dry_run, "Show what would have been synced, but don't sync it", :short => '-n'
24
+ opt :exclude, "Pathname or pattern to exclude", :short => :none, :multi => true, :type => :string
25
+ opt :exclude_from, "File containing pathnames and patterns to exclude", :short => :none, :multi => true, :type => :string
26
+ opt :rsync_path, "Path to the rsync binary", :short => :none, :default => '/usr/bin/rsync'
27
+ opt :verbosity, "Output verbosity: debug, info, warn, error, or fatal", :short => '-V', :default => 'info'
28
+ end
29
+
30
+ config[:remote_path] = ARGV.shift or abort("Error: Remote path not specified")
31
+ config[:local_path] = File.expand_path(ARGV.shift || '.') + '/'
32
+
33
+ config.merge!(options)
34
+
35
+ config[:exclude] ||= []
36
+ config[:exclude_from] ||= []
37
+
38
+ # Validate options.
39
+ abort("Error: Directory not found: #{config[:local_path]}") unless File.exist?(config[:local_path])
40
+ abort("Error: Not a directory: #{config[:local_path]}") unless File.directory?(config[:local_path])
41
+ abort("Error: rsync not found at #{config[:rsync_path]}") unless File.exist?(config[:rsync_path])
42
+
43
+ Synchrotron.init(config)
44
+
45
+ Synchrotron.log.info "Performing initial sync..."
46
+ Synchrotron.sync
47
+
48
+ Synchrotron.monitor
49
+
50
+ end
@@ -0,0 +1,72 @@
1
+ module Synchrotron; class Ignore
2
+
3
+ GLOB_PATTERNS = {'*' => '.*', '?' => '.'}
4
+ REGEX_COMMENT = /#.*$/
5
+ REGEX_REGEX = /^\s*(%r(.).*\2[imxouesn]*)\s*$/i
6
+
7
+ def initialize(list = [])
8
+ @cache = {}
9
+ @globs = []
10
+ @regexes = []
11
+
12
+ @list = list.to_a
13
+ @log = Synchrotron.log
14
+
15
+ compile(@list)
16
+ end
17
+
18
+ def add(list)
19
+ compile(list.to_a)
20
+ end
21
+
22
+ def add_file(filename)
23
+ File.open(filename, 'r') {|f| compile(f.readlines) }
24
+ end
25
+
26
+ def match(path)
27
+ _path = Synchrotron.relative_path(path.strip)
28
+
29
+ return @cache[_path] if @cache.has_key?(_path)
30
+
31
+ @cache[_path] = match = @globs.any? {|glob| _path =~ glob } ||
32
+ @regexes.any? {|regex| _path =~ regex }
33
+
34
+ @log.insane "Ignoring #{_path}" if match
35
+ match
36
+ end
37
+
38
+ private
39
+
40
+ def compile(list)
41
+ globs = []
42
+ regexes = []
43
+ lineno = 0
44
+
45
+ list.each do |line|
46
+ lineno += 1
47
+
48
+ # Strip comments.
49
+ line.sub!(REGEX_COMMENT, '')
50
+ line.strip!
51
+
52
+ # Skip empty lines.
53
+ next if line.empty?
54
+
55
+ if line =~ REGEX_REGEX
56
+ regexes << Thread.start { $SAFE = 4; eval($1) }.value
57
+ else
58
+ globs << glob_to_regex(line)
59
+ end
60
+ end
61
+
62
+ @cache = {}
63
+ @globs += globs
64
+ @regexes += regexes
65
+ end
66
+
67
+ def glob_to_regex(str)
68
+ regex = str.gsub(/(.)/) {|c| GLOB_PATTERNS[$1] || Regexp.escape(c) }
69
+ Regexp.new("#{regex}$")
70
+ end
71
+
72
+ end; end
@@ -0,0 +1,50 @@
1
+ module Synchrotron
2
+
3
+ class Logger
4
+ attr_reader :level, :output
5
+
6
+ LEVELS = {
7
+ :fatal => 0,
8
+ :error => 1,
9
+ :warn => 2,
10
+ :info => 3,
11
+ :debug => 4,
12
+ :insane => 5
13
+ }
14
+
15
+ def initialize(level = :info, output = $stdout)
16
+ self.level = level.to_sym
17
+ self.output = output
18
+ end
19
+
20
+ def const_missing(name)
21
+ return LEVELS[name] if LEVELS.key?(name)
22
+ raise NameError, "uninitialized constant: #{name}"
23
+ end
24
+
25
+ def method_missing(name, *args)
26
+ return log(name, *args) if LEVELS.key?(name)
27
+ raise NoMethodError, "undefined method: #{name}"
28
+ end
29
+
30
+ def level=(level)
31
+ raise ArgumentError, "invalid log level: #{level}" unless LEVELS.key?(level)
32
+ @level = level
33
+ end
34
+
35
+ def log(level, msg)
36
+ return true if LEVELS[level] > LEVELS[@level] || msg.nil? || msg.empty?
37
+ @output.puts "[#{Time.new.strftime('%b %d %H:%M:%S')}] [#{level}] #{msg}"
38
+ true
39
+
40
+ rescue => e
41
+ false
42
+ end
43
+
44
+ def output=(output)
45
+ raise ArgumentError, "output must be an instance of class IO" unless output.is_a?(IO)
46
+ @output = output
47
+ end
48
+ end
49
+
50
+ end
@@ -0,0 +1,32 @@
1
+ # Not currently used.
2
+
3
+ module Synchrotron; class Scanner
4
+ attr_reader :paths, :root
5
+
6
+ def initialize(root)
7
+ @paths = {}
8
+ @root = File.expand_path(root)
9
+ end
10
+
11
+ def scan(path = @root)
12
+ changed = {}
13
+ ignored = []
14
+
15
+ Find.find(path) do |p|
16
+ if Synchrotron.ignore.match(p)
17
+ ignored << p
18
+ Find.prune
19
+ end
20
+
21
+ old = @paths[p]
22
+ stat = File.lstat(p)
23
+
24
+ changed[p] = stat if old.nil? || stat.ino != old.ino ||
25
+ stat.mtime != old.mtime || stat.size != old.size
26
+ end
27
+
28
+ @paths.merge!(changed) unless changed.empty?
29
+ {:changed => changed.values.sort, :ignored => ignored}
30
+ end
31
+
32
+ end; end
@@ -0,0 +1,44 @@
1
+ module Synchrotron; class Stream
2
+
3
+ def initialize(paths, callback, since = OSX::KFSEventStreamEventIdSinceNow)
4
+ @log = Synchrotron.log
5
+ @started = false
6
+ @stream = OSX.FSEventStreamCreate(nil, callback, nil, paths.to_a, since, 0.5, 0)
7
+
8
+ raise "failed to create FSEventStream" unless check_stream
9
+
10
+ OSX.FSEventStreamScheduleWithRunLoop(@stream, OSX.CFRunLoopGetCurrent(),
11
+ OSX::KCFRunLoopDefaultMode)
12
+ end
13
+
14
+ def release
15
+ stop
16
+ OSX.FSEventStreamInvalidate(@stream)
17
+ OSX.FSEventStreamRelease(@stream)
18
+
19
+ @log.debug "FSEventStream released"
20
+ end
21
+
22
+ def start
23
+ return if @started
24
+ raise "failed to start FSEventStream" unless OSX.FSEventStreamStart(@stream)
25
+ @started = true
26
+
27
+ @log.debug "FSEventStream started"
28
+ end
29
+
30
+ def stop
31
+ return unless @started
32
+ OSX.FSEventStreamStop(@stream)
33
+ @started = false
34
+
35
+ @log.debug "FSEventStream stopped"
36
+ end
37
+
38
+ private
39
+
40
+ def check_stream
41
+ !!@stream
42
+ end
43
+
44
+ end; end
@@ -0,0 +1,9 @@
1
+ module Synchrotron
2
+ APP_NAME = 'Synchrotron'
3
+ APP_VERSION = '0.0.1'
4
+ APP_AUTHOR = 'Ryan Grove'
5
+ APP_EMAIL = 'ryan@wonko.com'
6
+ APP_URL = 'http://github.com/rgrove/synchrotron/'
7
+ APP_COPYRIGHT = 'Copyright (c) 2009 Ryan Grove <ryan@wonko.com>. All ' <<
8
+ 'rights reserved.'
9
+ end
@@ -0,0 +1,120 @@
1
+ # Prepend this file's directory to the include path if it's not there already.
2
+ $:.unshift(File.dirname(File.expand_path(__FILE__)))
3
+ $:.uniq!
4
+
5
+ require 'find'
6
+ require 'pathname'
7
+ require 'set'
8
+ require 'osx/foundation'
9
+
10
+ OSX.require_framework '/System/Library/Frameworks/CoreServices.framework/Frameworks/CarbonCore.framework'
11
+
12
+ require 'synchrotron/ignore'
13
+ require 'synchrotron/logger'
14
+ require 'synchrotron/stream'
15
+ require 'synchrotron/version'
16
+
17
+ module Synchrotron; class << self
18
+ attr_reader :config, :ignore, :log, :scanner
19
+
20
+ def init(config = {})
21
+ @config = {
22
+ :dry_run => false,
23
+ :exclude => [],
24
+ :exclude_from => [],
25
+ :local_path => File.expand_path('.'),
26
+ :remote_path => nil,
27
+
28
+ :rsync_options => [
29
+ '--compress',
30
+ '--human-readable',
31
+ '--links',
32
+ '--recursive',
33
+ '--times',
34
+ '--verbose'
35
+ ],
36
+
37
+ :rsync_path => '/usr/bin/rsync',
38
+ :verbosity => :info
39
+ }.merge(config)
40
+
41
+ @log = Logger.new(@config[:verbosity])
42
+ @ignore = Ignore.new(@config[:exclude])
43
+ @regex_rel = Regexp.new("^#{Regexp.escape(@config[:local_path].chomp('/'))}/?")
44
+
45
+ @config[:rsync_options] << '--dry-run' if @config[:dry_run]
46
+
47
+ local_exclude_file = File.join(@config[:local_path], '.synchrotron-exclude')
48
+ @config[:exclude_from] << local_exclude_file if File.exist?(local_exclude_file)
49
+ @config[:exclude_from].each {|filename| @ignore.add_file(filename) }
50
+
51
+ @callback = proc do |stream, context, event_count, paths, marks, event_ids|
52
+ changed = Set.new
53
+ paths.regard_as('*')
54
+ event_count.times {|i| changed.add(paths[i]) unless @ignore.match(paths[i]) }
55
+
56
+ changed = coalesce_changes(changed)
57
+ return if changed.empty?
58
+
59
+ @log.info "Change detected"
60
+ changed.each {|path| sync(path) }
61
+ end
62
+
63
+ @stream = Stream.new(config[:local_path], @callback)
64
+
65
+ @log.info "Local path : #{@config[:local_path]}"
66
+ @log.info "Remote path: #{@config[:remote_path]}"
67
+ end
68
+
69
+ def coalesce_changes(changes)
70
+ coalesced = {}
71
+
72
+ changes.each do |path|
73
+ next if coalesced.include?(path)
74
+
75
+ pn = Pathname.new(path)
76
+
77
+ coalesced[pn.to_s] = true unless catch :matched do
78
+ pn.descend {|p| throw(:matched, true) if coalesced.include?(p.to_s) }
79
+ false
80
+ end
81
+ end
82
+
83
+ coalesced.keys.sort
84
+ end
85
+
86
+ def monitor
87
+ @log.info "Watching for changes"
88
+ @stream.start
89
+
90
+ begin
91
+ OSX.CFRunLoopRun()
92
+ rescue Interrupt
93
+ @stream.release
94
+ end
95
+ end
96
+
97
+ def relative_path(path)
98
+ path.sub(@regex_rel, '')
99
+ end
100
+
101
+ def sync(path = @config[:local_path])
102
+ rsync_local = escape_arg(path)
103
+ rsync_options = @config[:rsync_options].join(' ')
104
+ rsync_remote = escape_arg(File.join(@config[:remote_path], relative_path(path)))
105
+
106
+ # Build exclusion list.
107
+ @config[:exclude].each {|p| rsync_options << " --exclude #{escape_arg(p)}" }
108
+ @config[:exclude_from].each {|f| rsync_options << " --exclude-from #{escape_arg(f)}"}
109
+
110
+ puts `#{@config[:rsync_path]} #{rsync_options} #{rsync_local} #{rsync_remote}`
111
+ puts
112
+ end
113
+
114
+ private
115
+
116
+ def escape_arg(str)
117
+ "'#{str.gsub("'", "\\'")}'"
118
+ end
119
+
120
+ end; end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: synchrotron
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Ryan Grove
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2010-01-04 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: trollop
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ~>
22
+ - !ruby/object:Gem::Version
23
+ version: "1.13"
24
+ version:
25
+ description:
26
+ email: ryan@wonko.com
27
+ executables:
28
+ - synchrotron
29
+ extensions: []
30
+
31
+ extra_rdoc_files: []
32
+
33
+ files:
34
+ - bin/synchrotron
35
+ - lib/synchrotron.rb
36
+ - lib/synchrotron/ignore.rb
37
+ - lib/synchrotron/logger.rb
38
+ - lib/synchrotron/scanner.rb
39
+ - lib/synchrotron/stream.rb
40
+ - lib/synchrotron/version.rb
41
+ has_rdoc: true
42
+ homepage: http://github.com/rgrove/synchrotron/
43
+ licenses: []
44
+
45
+ post_install_message:
46
+ rdoc_options: []
47
+
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 1.8.6
55
+ version:
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: "0"
61
+ version:
62
+ requirements: []
63
+
64
+ rubyforge_project:
65
+ rubygems_version: 1.3.5
66
+ signing_key:
67
+ specification_version: 3
68
+ summary: Synchrotron monitors a local directory tree and performs nearly instantaneous one-way synchronization of changes to a remote directory.
69
+ test_files: []
70
+