synchrotron 0.0.1

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/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
+