synchrotron 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/synchrotron +50 -0
- data/lib/synchrotron/ignore.rb +72 -0
- data/lib/synchrotron/logger.rb +50 -0
- data/lib/synchrotron/scanner.rb +32 -0
- data/lib/synchrotron/stream.rb +44 -0
- data/lib/synchrotron/version.rb +9 -0
- data/lib/synchrotron.rb +120 -0
- metadata +70 -0
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
|
data/lib/synchrotron.rb
ADDED
@@ -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
|
+
|