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