dm-svn 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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