dm-svn 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +23 -0
- data/Rakefile +56 -0
- data/VERSION +1 -0
- data/dm-svn.gemspec +85 -0
- data/lib/dm-svn.rb +13 -0
- data/lib/dm-svn/config.rb +38 -0
- data/lib/dm-svn/model.rb +12 -0
- data/lib/dm-svn/svn.rb +144 -0
- data/lib/dm-svn/svn/categorized.rb +113 -0
- data/lib/dm-svn/svn/changeset.rb +119 -0
- data/lib/dm-svn/svn/node.rb +128 -0
- data/lib/dm-svn/svn/sync.rb +85 -0
- data/spec/dm-svn/config_spec.rb +51 -0
- data/spec/dm-svn/database.yml +16 -0
- data/spec/dm-svn/fixtures/articles_comments.rb +95 -0
- data/spec/dm-svn/mock_models.rb +53 -0
- data/spec/dm-svn/model_spec.rb +5 -0
- data/spec/dm-svn/spec_helper.rb +50 -0
- data/spec/dm-svn/svn/categorized_spec.rb +138 -0
- data/spec/dm-svn/svn/changeset_spec.rb +42 -0
- data/spec/dm-svn/svn/node_spec.rb +125 -0
- data/spec/dm-svn/svn/sync_spec.rb +111 -0
- data/spec/dm-svn/svn_spec.rb +213 -0
- data/spec/spec.opts +0 -0
- data/spec/spec_helper.rb +23 -0
- metadata +132 -0
@@ -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,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
|