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