jm81-dm-svn 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/.gitignore ADDED
@@ -0,0 +1,22 @@
1
+ .DS_Store
2
+ log/*
3
+ tmp/*
4
+ TAGS
5
+ *~
6
+ .#*
7
+ schema/schema.rb
8
+ schema/*_structure.sql
9
+ schema/*.sqlite3
10
+ schema/*.sqlite
11
+ schema/*.db
12
+ *.sqlite
13
+ *.sqlite3
14
+ *.db
15
+ src/*
16
+ .hgignore
17
+ .hg/*
18
+ .svn/*
19
+ .project
20
+ .loadpath
21
+ lib/wistle/tmp/*
22
+ config/recaptcha.yml
data/Rakefile ADDED
@@ -0,0 +1,52 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "dm-svn"
8
+ gem.summary = %Q{Sync content from a Subversion repository to a DataMapper model}
9
+ gem.email = "jmorgan@morgancreative.net"
10
+ gem.homepage = "http://github.com/jm81/dm-svn"
11
+ gem.authors = ["Jared Morgan"]
12
+ gem.add_dependency('dm-core')
13
+ gem.add_dependency('dm-aggregates')
14
+ gem.add_dependency('dm-validations')
15
+ gem.add_dependency('jm81-svn-fixture', '>= 0.1.1')
16
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
17
+ end
18
+
19
+ rescue LoadError
20
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
21
+ end
22
+
23
+ require 'spec/rake/spectask'
24
+ Spec::Rake::SpecTask.new(:spec) do |spec|
25
+ spec.libs << 'lib' << 'spec'
26
+ spec.spec_files = FileList['spec/**/*_spec.rb']
27
+ end
28
+
29
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
30
+ spec.libs << 'lib' << 'spec'
31
+ spec.pattern = 'spec/**/*_spec.rb'
32
+ spec.rcov = true
33
+ end
34
+
35
+
36
+ task :default => :spec
37
+
38
+ require 'rake/rdoctask'
39
+ Rake::RDocTask.new do |rdoc|
40
+ if File.exist?('VERSION.yml')
41
+ config = YAML.load(File.read('VERSION.yml'))
42
+ version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
43
+ else
44
+ version = ""
45
+ end
46
+
47
+ rdoc.rdoc_dir = 'rdoc'
48
+ rdoc.title = "dm-svn #{version}"
49
+ rdoc.rdoc_files.include('README*')
50
+ rdoc.rdoc_files.include('lib/**/*.rb')
51
+ end
52
+
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,38 @@
1
+ module DmSvn
2
+ class Config
3
+ OPTS = [:uri, :username, :password,
4
+ :body_property, :property_prefix, :extension]
5
+
6
+ attr_accessor *OPTS
7
+ attr_accessor :path_from_root # Used by Sync.
8
+
9
+ def initialize
10
+ # Set defaults
11
+ @body_property = 'body'
12
+ @property_prefix = 'ws:'
13
+ @extension = 'txt'
14
+
15
+ # Try to set variables from database.yml.
16
+ # The location of database.yml should, I suppose, be configurable.
17
+ # Oh, well.
18
+ if Object.const_defined?("RAILS_ROOT")
19
+ f = "#{RAILS_ROOT}/config/database.yml"
20
+ env = Kernel.const_defined?("RAILS_ENV") ? RAILS_ENV : "development"
21
+ elsif Object.const_defined?("Merb")
22
+ f = "#{Merb.root}/config/database.yml"
23
+ env = Merb.env.to_sym || :development
24
+ end
25
+
26
+ if f
27
+ config = YAML.load(IO.read(f))[env]
28
+ OPTS.each do |field|
29
+ config_field = config["svn_#{field}"] || config["svn_#{field}".to_sym]
30
+ if config_field
31
+ instance_variable_set("@#{field}", config_field)
32
+ end
33
+ end
34
+ end
35
+
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ module DmSvn
2
+ class Model
3
+ include DataMapper::Resource
4
+
5
+ property :id, Integer, :serial => true
6
+ property :name, String
7
+ property :revision, Integer
8
+
9
+ # DmSvn::Config object
10
+ attr_accessor :config
11
+ end
12
+ end
@@ -0,0 +1,113 @@
1
+ module DmSvn
2
+ module Svn
3
+
4
+ module ClassMethods
5
+
6
+ # Override belongs_to to add a :dm-svn option if :dm-svn => true, include
7
+ # Categorized and set up @svn_category and @svn_category_model instance
8
+ # methods.
9
+ def belongs_to(what, options = {})
10
+ svn = options.delete(:svn)
11
+ if svn
12
+ @svn_category = what
13
+ @svn_category_model = options[:class_name] || what.to_s.camel_case
14
+ include(DmSvn::Svn::Categorized)
15
+ end
16
+
17
+ super
18
+ end
19
+
20
+ # Method name for accessing the parent instance.
21
+ def svn_category
22
+ @svn_category
23
+ end
24
+
25
+ # Name of the parent model class (as a String)
26
+ def svn_category_model
27
+ @svn_category_model
28
+ end
29
+
30
+ end
31
+
32
+ # This module is including when belongs_to is called with :dm-svn => true.
33
+ # It overrides #path and #path= to take into account categories (folders in
34
+ # the Subversion repository). It also overrides .get to accept get_parent
35
+ # argument.
36
+ module Categorized
37
+ class << self
38
+ def included(klass)
39
+ klass.extend(ClassMethods)
40
+ end
41
+ end
42
+
43
+ # The path from the svn root for the model. Includes any folders.
44
+ def path
45
+ cat = self.send(self.class.svn_category)
46
+
47
+ if cat && !cat.path.blank?
48
+ return cat.path + "/" + @svn_name
49
+ end
50
+
51
+ return @svn_name
52
+ end
53
+
54
+ # Set the path. This is responsible for moving the record to a different
55
+ # parent, etc.
56
+ def path=(value)
57
+ value = value[1..-1] while value[0..0] == "/"
58
+ ary = value.split("/")
59
+ immediate = ary.pop
60
+ parent = ary.join("/")
61
+
62
+ if parent.blank?
63
+ self.send("#{self.class.svn_category}=", nil)
64
+ else
65
+ category_model = Object.const_get(self.class.svn_category_model)
66
+ category = category_model.get_or_create(parent)
67
+ self.send("#{self.class.svn_category}=", category)
68
+ end
69
+
70
+ attribute_set(:svn_name, immediate)
71
+ end
72
+
73
+ module ClassMethods
74
+
75
+ # Get by path, which gets parent (possibly recursively) first.
76
+ def get_by_path(value)
77
+ value = value[1..-1] while value[0..0] == "/"
78
+ ary = value.split("/")
79
+ immediate = ary.pop
80
+ parent = ary.join("/")
81
+
82
+ if parent.blank?
83
+ first(:svn_name => immediate, "#{self.svn_category}_id".to_sym => nil)
84
+ else
85
+ category_model = Object.const_get(self.svn_category_model)
86
+ category = category_model.get_by_path(parent)
87
+ return nil if category.nil?
88
+ first(:svn_name => immediate, "#{self.svn_category}_id".to_sym => category.id)
89
+ end
90
+ end
91
+
92
+ # Add get_parent argument. If true and a path is not found for the
93
+ # model, try to find path in parent model (if any).
94
+ def get(path_or_id, get_parent = false)
95
+ if path_or_id.is_a?(String)
96
+ i = get_by_path(path_or_id)
97
+ if i || !get_parent
98
+ return i
99
+ else # if get_parent
100
+ category_model = Object.const_get(@svn_category_model)
101
+ return nil if category_model == self.class
102
+ category_model.get_by_path(path_or_id)
103
+ end
104
+ else
105
+ super(path_or_id)
106
+ end
107
+ end
108
+
109
+ end
110
+
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,119 @@
1
+ module DmSvn
2
+ module Svn
3
+
4
+ # Store information about a particular changeset, and runs the actual
5
+ # updates for that changeset. Aside from handling the
6
+ # list of changes, it can be passed to methods that need the revision,
7
+ # date and author.
8
+ class Changeset
9
+ include Comparable
10
+
11
+ attr_reader :changes, :revision, :date, :author, :repos, :config
12
+
13
+ def initialize(changes, revision, author, date, sync)
14
+ @changes, @revision, @author, @date = changes, revision, author, date
15
+ @model, @config, @repos, @sync = sync.model, sync.config, sync.repos, sync
16
+ end
17
+
18
+ # Changesets are sorted by revision number, ascending.
19
+ def <=>(other)
20
+ self.revision <=> other.revision
21
+ end
22
+
23
+ # Process this changeset.
24
+ # This doesn't account for possible move/replace conflicts (A node is moved,
25
+ # then the old node is replaced by a new one). I assume those are rare
26
+ # enough that I won't code around them, for now.
27
+ def process
28
+ return if changes.nil?
29
+
30
+ modified, deleted, copied = [], [], []
31
+
32
+ changes.each_pair do |path, change|
33
+ next if short_path(path).blank? || !path_in_root?(path)
34
+
35
+ case change.action
36
+ when "M", "A", "R" # Modified, Added or Replaced
37
+ modified << path
38
+ when "D"
39
+ deleted << path
40
+ end
41
+ copied << [path, change.copyfrom_path] if change.copyfrom_path
42
+ end
43
+
44
+ # Perform moves
45
+ copied.each do |copy|
46
+ del = deleted.find { |d| d == copy[1] }
47
+ if del
48
+ # Change the path. No need to perform other updates, as this is an
49
+ # "A" or "R" and thus is in the +modified+ Array.
50
+ record = get(del)
51
+ record.move_to(short_path(copy[0])) if record
52
+ end
53
+ end
54
+
55
+ # Perform deletes
56
+ deleted.each do |path|
57
+ record = get(path)
58
+ record.destroy if record # May have been moved or refer to a directory
59
+ end
60
+
61
+ # Perform modifies and adds
62
+ modified.each do |path|
63
+ node = Node.new(self, path)
64
+
65
+ if @config.extension &&
66
+ node.file? &&
67
+ path !~ /\.#{@config.extension}\Z/
68
+ if path =~ /\.yml\Z/ && @model.svn_category_model
69
+ # Update parent directory, if applicable
70
+ path = path.split("/")[0..-2].join("/")
71
+ node = Node.new(self, path)
72
+ else
73
+ next
74
+ end
75
+ end
76
+
77
+ record = get(path) || new_record(node)
78
+
79
+ # update record
80
+ record.update_from_svn(node)
81
+ end
82
+ end
83
+
84
+ # Get the relative path from config.uri
85
+ def short_path(path)
86
+ path = fs_path(path)
87
+ path = path.sub(/\.#{@config.extension}\Z/, '') if @config.extension
88
+ path
89
+ end
90
+
91
+ # Path used to communicate with svn repos.
92
+ def fs_path(path)
93
+ path = path[@config.path_from_root.length..-1].to_s
94
+ path = path[1..-1] if path[0] == ?/
95
+ path
96
+ end
97
+
98
+ private
99
+
100
+ def path_in_root?(path)
101
+ return true if @config.path_from_root.blank?
102
+ path[0..@config.path_from_root.length - 1].to_s == @config.path_from_root
103
+ end
104
+
105
+ # Get an object of the @model, by path.
106
+ def get(path)
107
+ @model.get(short_path(path), true)
108
+ end
109
+
110
+ # Create a new object of the @model
111
+ def new_record(node)
112
+ node.file? ?
113
+ @model.new :
114
+ Object.const_get(@model.svn_category_model).new
115
+ end
116
+
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,128 @@
1
+ module DmSvn
2
+ module Svn
3
+
4
+ # A Node (file or directory) at a path and revision [Changeset].
5
+ # In particular, this class exists to determine the type (file or directory)
6
+ # of the node and retrieve the body of a node (for files),
7
+ # and other properties, either stored in yaml or a Subversion properties
8
+ class Node
9
+
10
+ attr_reader :path
11
+
12
+ def initialize(changeset, path)
13
+ @changeset, @path = changeset, path
14
+ end
15
+
16
+ # Shortened path (from Changeset#short_path)
17
+ def short_path
18
+ @changeset.short_path(@path)
19
+ end
20
+
21
+ # Shortened path (from Changeset#fs_path)
22
+ def fs_path
23
+ @changeset.fs_path(@path)
24
+ end
25
+
26
+ # Body of the node (nil for a directory)
27
+ def body
28
+ return nil unless file?
29
+ has_yaml_props? ?
30
+ yaml_split[1] :
31
+ data[0]
32
+ end
33
+
34
+ # Properties of the node. The properties are accessed from three sources,
35
+ # listed by order of precedence:
36
+ # 1. YAML properties, either in meta.yml (directories), or at the
37
+ # beginning of a file, between "---" and "..." lines.
38
+ # 2. Properties stored in subversion's property mechanism.
39
+ # 3. svn_updated_[rev|at|by] as determined by revision properties.
40
+ def properties
41
+ rev_properties.merge(svn_properties).merge(yaml_properties)
42
+ end
43
+
44
+ # Is the Node a file?
45
+ def file?
46
+ repos.stat(fs_path, revision).file?
47
+ end
48
+
49
+ # Is the Node a directory?
50
+ def directory?
51
+ repos.stat(fs_path, revision).directory?
52
+ end
53
+
54
+ # Methods derived from Changeset instance variables.
55
+ %w{revision author repos date config}.each do |f|
56
+ define_method(f) do
57
+ @changeset.__send__(f)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ # Get data as array of body, properties
64
+ def data
65
+ @data ||= file? ?
66
+ repos.file(fs_path, revision) :
67
+ repos.dir(fs_path, revision)
68
+ end
69
+
70
+ # Properties based on the revision information: svn_updated_[rev|at|by]
71
+ # as a hash
72
+ def rev_properties
73
+ {
74
+ 'svn_updated_at' => @changeset.date,
75
+ 'svn_updated_by' => @changeset.author,
76
+ 'svn_updated_rev' => @changeset.revision
77
+ }
78
+ end
79
+
80
+ # Get properties stored as subversion properties, that begin with the
81
+ # Config#property_prefix.
82
+ def svn_properties
83
+ props = {}
84
+ data[1].each do |name, val|
85
+ if name =~ /\A#{config.property_prefix}(.*)/
86
+ props[$1] = val
87
+ end
88
+ end
89
+ props
90
+ end
91
+
92
+ # Get YAML properties. For a directory, these are stored in meta.yml,
93
+ # for a file, they are stored at the beginning of the file: the first line
94
+ # will be "---" and the YAML will end before a line containing "..."
95
+ def yaml_properties
96
+ if directory?
97
+ fs_yaml_path = fs_path.blank? ? 'meta.yml' : File.join(fs_path, 'meta.yml')
98
+ yaml_path = File.join(@path, 'meta.yml')
99
+ repos.stat(fs_yaml_path, revision) ?
100
+ YAML.load(self.class.new(@changeset, yaml_path).body) :
101
+ {}
102
+ else
103
+ has_yaml_props? ?
104
+ YAML.load(yaml_split[0]) :
105
+ {}
106
+ end
107
+ end
108
+
109
+ # Determine if file has yaml properties, by checking if the file starts
110
+ # with three leading dashes
111
+ def has_yaml_props?
112
+ file? && data[0][0..2] == "---"
113
+ end
114
+
115
+ # Split a file between properties and body at a line with three dots.
116
+ # Left trim the body and there may have been blank lines added for
117
+ # clarity.
118
+ def yaml_split
119
+ data[0].gsub!("\r", "")
120
+ ary = data[0].split("\n...\n")
121
+ ary[1] = ary[1].lstrip
122
+ ary
123
+ end
124
+
125
+ end
126
+ end
127
+ end
128
+
@@ -0,0 +1,85 @@
1
+ module DmSvn
2
+ module Svn
3
+ class Sync
4
+
5
+ attr_reader :model, :config, :repos
6
+
7
+ def initialize(model_row)
8
+ @model_row = model_row
9
+ @model = Object.const_get(@model_row.name)
10
+ @config = @model_row.config
11
+ end
12
+
13
+ # Get changesets
14
+ def changesets
15
+ sets = []
16
+
17
+ @repos.log('', @model_row.revision, @repos.latest_revnum, 0, true, false
18
+ ) do |changes, rev, author, date, msg|
19
+ sets << Changeset.new(changes, rev, author, date, self)
20
+ end
21
+
22
+ sets.sort
23
+ end
24
+
25
+ # There is the possibility for uneccessary updates, as a database row may be
26
+ # modified several times (if modified in multiple revisions) in a single
27
+ # call. This is inefficient, but--for now--not enough to justify more
28
+ # complex code.
29
+ def run
30
+ connect(@config.uri)
31
+ return false if @repos.latest_revnum <= @model_row.revision
32
+
33
+ changesets.each do |c| # Sorted by revision, ascending
34
+ c.process
35
+ # Update model_row.revision
36
+ row_update = @model_row.class.get(@model_row.id)
37
+ row_update.update_attributes(:revision => c.revision)
38
+ end
39
+
40
+ # Ensure that @model_row.revision is now set to the latest (even if there
41
+ # weren't applicable changes in the latest revision).
42
+ row_update = @model_row.class.get(@model_row.id)
43
+ row_update.update_attributes(:revision => @repos.latest_revnum)
44
+ return true
45
+ end
46
+
47
+ private
48
+
49
+ def connect(uri)
50
+ @ctx = context(uri)
51
+
52
+ # This will raise some error if connection fails for whatever reason.
53
+ # I don't currently see a reason to handle connection errors here, as I
54
+ # assume the best handling would be to raise another error.
55
+ @repos = ::Svn::Ra::Session.open(uri, {}, callbacks)
56
+ @config.path_from_root = @config.uri[(@repos.repos_root.length)..-1]
57
+ return true
58
+ end
59
+
60
+ def context(uri)
61
+ # Client::Context, which paticularly holds an auth_baton.
62
+ ctx = ::Svn::Client::Context.new
63
+ if @config.username && @config.password
64
+ # TODO: What if another provider type is needed? Is this plausible?
65
+ ctx.add_simple_prompt_provider(0) do |cred, realm, username, may_save|
66
+ cred.username = @config.username
67
+ cred.password = @config.password
68
+ end
69
+ elsif URI.parse(uri).scheme == "file"
70
+ ctx.add_username_prompt_provider(0) do |cred, realm, username, may_save|
71
+ cred.username = @config.username || "ANON"
72
+ end
73
+ else
74
+ ctx.auth_baton = ::Svn::Core::AuthBaton.new()
75
+ end
76
+ ctx
77
+ end
78
+
79
+ # callbacks for Svn::Ra::Session.open. This includes the client +context+.
80
+ def callbacks
81
+ ::Svn::Ra::Callbacks.new(@ctx.auth_baton)
82
+ end
83
+ end
84
+ end
85
+ end