dm-svn 0.2.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.
@@ -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(: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(: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
@@ -0,0 +1,51 @@
1
+ require 'spec_helper'
2
+
3
+ describe DmSvn::Config do
4
+ before(:each) do
5
+ @c = DmSvn::Config.new
6
+ end
7
+
8
+ it "should initialize @body_property to 'body'" do
9
+ @c.body_property.should == 'body'
10
+ end
11
+
12
+ it "should modify @body_property" do
13
+ @c.body_property = 'contents'
14
+ @c.body_property.should == 'contents'
15
+ end
16
+
17
+ it "should initialize @property_prefix to 'ws:'" do
18
+ @c.property_prefix.should == 'ws:'
19
+ end
20
+
21
+ it "should initialize @extension to 'txt'" do
22
+ @c.extension.should == 'txt'
23
+ end
24
+
25
+ it "should load config options from database.yml" do
26
+ Merb = Object.new
27
+ def Merb.root
28
+ '/path/to/merb'
29
+ end
30
+
31
+ def Merb.env(*args)
32
+ :test
33
+ end
34
+
35
+ f = "#{Merb.root}/config/database.yml"
36
+ yaml = File.read(File.dirname(__FILE__) + '/database.yml')
37
+ IO.should_receive(:read).with(f).and_return(yaml)
38
+
39
+ c = DmSvn::Config.new
40
+ c.username.should == 'login'
41
+ c.password.should == 'pw1234'
42
+ c.extension.should == 'doc'
43
+ c.body_property.should == 'body' # Didn't change
44
+ Object.__send__(:remove_const, :Merb)
45
+ end
46
+
47
+ after(:all) do
48
+ # ensure this happens
49
+ Object.__send__(:remove_const, :Merb) if Object.const_defined?(:Merb)
50
+ end
51
+ end
@@ -0,0 +1,16 @@
1
+ ---
2
+
3
+ :development: &defaults
4
+ :adapter: sqlite3
5
+ :database: dev.db
6
+
7
+ :test:
8
+ <<: *defaults
9
+ :database: test.db
10
+ :svn_username: login
11
+ :svn_password: pw1234
12
+ svn_extension: doc
13
+
14
+ :production:
15
+ <<: *defaults
16
+ :database: pro.db
@@ -0,0 +1,95 @@
1
+ # This is a repository representing information for two models in one repo.
2
+
3
+ SvnFixture.repo('articles_comments') do
4
+ revision(1, 'Create articles and comments directories',
5
+ :date => Time.parse("2007-01-01")) do
6
+ dir 'articles'
7
+ dir 'comments'
8
+ end
9
+
10
+ revision 2, 'Create articles about computers and philosophy' do
11
+ dir 'articles' do
12
+ prop 'ws:title', 'Articles'
13
+
14
+ file 'philosophy.txt' do
15
+ prop 'ws:title', 'Philosophy'
16
+ prop 'ws:published_at', (Time.now - 2 * 24 * 3600) # 2.days.ago
17
+ body 'My philosophy is to eat a lot of salsa!'
18
+ end
19
+
20
+ file 'computers.txt' do
21
+ prop 'ws:title', 'Computers'
22
+ prop 'ws:published_at', (Time.now - 1 * 24 * 3600) # 1.day.ago
23
+ body 'Computers do not like salsa so much.'
24
+ end
25
+ end
26
+ end
27
+
28
+ revision 3, 'Write unpublished Article', :author => "author" do
29
+ dir 'articles' do
30
+ file 'unpublished.txt' do
31
+ prop 'ws:title', 'Private Thoughts'
32
+ body "See, it's not published.\nYou can't read it."
33
+ end
34
+ end
35
+ end
36
+
37
+ revision 4, 'Decide to publish unpublished Article' do
38
+ dir 'articles' do
39
+ file 'unpublished.txt' do
40
+ prop 'ws:published_at', (Time.now - 3600) # 1.hour.ago
41
+ end
42
+ end
43
+ end
44
+
45
+ revision 5, 'Update text of Computer article' do
46
+ dir 'articles' do
47
+ file 'computers.txt' do
48
+ body 'Computers do not like salsa very much.'
49
+ end
50
+ end
51
+ end
52
+
53
+ revision 6, 'Add a couple of comments' do
54
+ dir 'comments' do
55
+ file 'computers_1.txt' do
56
+ prop 'ws:article', 'computers.txt'
57
+ body "They don't like most liquids"
58
+ end
59
+ end
60
+
61
+ dir 'comments' do
62
+ file 'computers_2.txt' do
63
+ prop 'ws:article', 'computers.txt'
64
+ body "OH, RLY?"
65
+ end
66
+ end
67
+ end
68
+
69
+ revision 7, 'Moves and copies' do
70
+ dir 'articles' do
71
+ move 'unpublished.txt', 'just_published.txt'
72
+ copy 'computers.txt', 'computations.txt'
73
+ end
74
+ end
75
+
76
+ revision 8, 'Delete computers.txt' do
77
+ dir 'articles' do
78
+ delete 'computers.txt'
79
+ end
80
+ end
81
+
82
+ revision 9, 'Add nodes with YAML properties' do
83
+ dir 'articles' do
84
+ file 'meta.yml' do
85
+ body "title: Lots of Articles\nrandom_number: 7"
86
+ end
87
+
88
+ file 'turtle.txt' do
89
+ body "---\ntitle: Turtle\nrandom_number: 2\n...\nHi, turtle."
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ SvnFixture.repo('articles_comments').commit