perforce2svn 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +2 -0
- data/Gemfile.lock +38 -0
- data/LICENSE +23 -0
- data/README.markdown +66 -0
- data/Rakefile +24 -0
- data/bin/perforce2svn +11 -0
- data/lib/VERSION.yml +6 -0
- data/lib/perforce2svn/cli.rb +117 -0
- data/lib/perforce2svn/environment.rb +66 -0
- data/lib/perforce2svn/errors.rb +16 -0
- data/lib/perforce2svn/logging.rb +35 -0
- data/lib/perforce2svn/mapping/analyzer.rb +30 -0
- data/lib/perforce2svn/mapping/branch_mapping.rb +32 -0
- data/lib/perforce2svn/mapping/commands.rb +75 -0
- data/lib/perforce2svn/mapping/help.txt +139 -0
- data/lib/perforce2svn/mapping/lexer.rb +101 -0
- data/lib/perforce2svn/mapping/mapping_file.rb +65 -0
- data/lib/perforce2svn/mapping/operation.rb +8 -0
- data/lib/perforce2svn/mapping/parser.rb +145 -0
- data/lib/perforce2svn/migrator.rb +71 -0
- data/lib/perforce2svn/perforce/commit_builder.rb +159 -0
- data/lib/perforce2svn/perforce/p4_depot.rb +69 -0
- data/lib/perforce2svn/perforce/perforce_file.rb +81 -0
- data/lib/perforce2svn/subversion/svn_repo.rb +156 -0
- data/lib/perforce2svn/subversion/svn_transaction.rb +136 -0
- data/lib/perforce2svn/version_range.rb +43 -0
- data/mjt.map +7 -0
- data/perforce2svn.gemspec +49 -0
- data/spec/integration/hamlet.txt +7067 -0
- data/spec/integration/madmen_icon_bigger.jpg +0 -0
- data/spec/integration/perforce/p4_depot_spec.rb +16 -0
- data/spec/integration/perforce/perforce_file.yml +4 -0
- data/spec/integration/perforce/perforce_file_spec.rb +19 -0
- data/spec/integration/subversion/svn_repo_spec.rb +93 -0
- data/spec/integration/subversion/svn_transaction_spec.rb +112 -0
- data/spec/perforce2svn/cli_spec.rb +61 -0
- data/spec/perforce2svn/mapping/analyzer_spec.rb +41 -0
- data/spec/perforce2svn/mapping/branch_mapping_spec.rb +40 -0
- data/spec/perforce2svn/mapping/lexer_spec.rb +43 -0
- data/spec/perforce2svn/mapping/parser_spec.rb +140 -0
- data/spec/perforce2svn/perforce/commit_builder_spec.rb +74 -0
- data/spec/perforce2svn/version_range_spec.rb +42 -0
- data/spec/spec_helpers.rb +44 -0
- metadata +230 -0
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
perforce2svn (0.1.0)
|
5
|
+
choosy (>= 0.4.8)
|
6
|
+
log4r (>= 1.1.7)
|
7
|
+
|
8
|
+
GEM
|
9
|
+
remote: http://rubygems.org/
|
10
|
+
specs:
|
11
|
+
ZenTest (4.5.0)
|
12
|
+
autotest (4.4.6)
|
13
|
+
ZenTest (>= 4.4.1)
|
14
|
+
autotest-notification (2.3.1)
|
15
|
+
autotest (~> 4.3)
|
16
|
+
choosy (0.4.8)
|
17
|
+
diff-lcs (1.1.2)
|
18
|
+
log4r (1.1.9)
|
19
|
+
mocha (0.9.12)
|
20
|
+
rspec (2.5.0)
|
21
|
+
rspec-core (~> 2.5.0)
|
22
|
+
rspec-expectations (~> 2.5.0)
|
23
|
+
rspec-mocks (~> 2.5.0)
|
24
|
+
rspec-core (2.5.1)
|
25
|
+
rspec-expectations (2.5.0)
|
26
|
+
diff-lcs (~> 1.1.2)
|
27
|
+
rspec-mocks (2.5.0)
|
28
|
+
|
29
|
+
PLATFORMS
|
30
|
+
ruby
|
31
|
+
|
32
|
+
DEPENDENCIES
|
33
|
+
ZenTest
|
34
|
+
autotest
|
35
|
+
autotest-notification
|
36
|
+
mocha
|
37
|
+
perforce2svn!
|
38
|
+
rspec
|
data/LICENSE
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
MIT LICENSE
|
2
|
+
|
3
|
+
Copyright (c) 2011 Gabe McArthur
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
22
|
+
|
23
|
+
This Software shall be used for Good, not Evil.
|
data/README.markdown
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
# Perforce2Svn Migration Tool
|
2
|
+
|
3
|
+
This tool is designed to help you with relatively sophisticated migrations from a Perforce server into Subversion. The Subversion repository must be on the file system for this command to function correctly. If a repository does not exist, one will be created.
|
4
|
+
|
5
|
+
## Prerequisites && Installation
|
6
|
+
|
7
|
+
This gem has only been tested on Ubuntu, though it should work on any Linux/Unix derivative with the correct binary libraries. It has only been tested on 1.8.7, though it should work on 1.8.6; it most certainly does not work at all under 1.9, particularly since the binary libraries are build against 1.8.
|
8
|
+
|
9
|
+
Sidenote: if you do run Ubuntu (or Debian), please do yourself a favor and install the latest from rubygems.org. Gem packaging and maintenance on Debian is complete crap and should not be trusted.
|
10
|
+
|
11
|
+
For this gem to function correctly, you will need to install certain tools on your own. You will need at least:
|
12
|
+
|
13
|
+
- <code>svnadmin</code>: Required for cleaning up stale transactions if you Ctrl-C during a migration.
|
14
|
+
- <code>p4</code>: The Perforce command line utility should be installed. You will need to log in with it (or p4v) before you can do any migration.
|
15
|
+
|
16
|
+
In addition to these console tools, you will need to install some gems on your own. First, the <code>p4ruby (>= 1.0.9)</code> gem is required. I would list it as a dependency in the gemspec, but I have yet to have it succesfully install itself on its own. You should try the following:
|
17
|
+
|
18
|
+
gem install p4ruby
|
19
|
+
# ... Watch it fail, record the FTP url so that you can go to the 10.2 sources
|
20
|
+
cd ${GEM_HOME}/gems/p4ruby-1.0.9/
|
21
|
+
ruby install.rb --version 10.2 --platform ${YOUR_PLATFORM_HERE}
|
22
|
+
|
23
|
+
Additionally, you will need to install the Subversion bindings for Ruby. These should install their code under RUBY_HOME. On Ubuntu, you can run:
|
24
|
+
|
25
|
+
apt-get install libsvn-ruby
|
26
|
+
|
27
|
+
I'm sure there are other binary packages out there for other platforms.
|
28
|
+
|
29
|
+
At this point, you can now install the main gem:
|
30
|
+
|
31
|
+
gem install perforce2svn
|
32
|
+
|
33
|
+
This installs the <code>perforce2svn</code> command line client onto your path.
|
34
|
+
|
35
|
+
## Usage
|
36
|
+
|
37
|
+
The <code>perforce2svn</code> tool requires a mapping file that describes the migration activities that should occur. The general outline of a mapping file should look something like this:
|
38
|
+
|
39
|
+
# This is a comment.
|
40
|
+
migrate //depot/from/perforce/path /to/svn/trunk
|
41
|
+
migrate //depot/another/path /to/svn/trunk/subdir
|
42
|
+
|
43
|
+
# Post-migration actions can also occur:
|
44
|
+
copy /svn/path/in/tree /to/another/path
|
45
|
+
move /same/as/copy /but/gets/rid/of/old/path
|
46
|
+
delete /deletes/svn/file
|
47
|
+
|
48
|
+
# You can even add files at the end of a migration
|
49
|
+
update /live/file/in/tree.txt
|
50
|
+
|
51
|
+
The mapping file is fairly utilitarian, so you should feel free take what you need and leave the rest. There is a much more detailed discussion of the format of the mapping file and how to use it if you run <code>perforce2svn --mapping-file</code>.
|
52
|
+
|
53
|
+
The only quirky bit is the use of the <code>--live-path</code> flag, which points at the base directory of some directory structure that you want to use in your mapping file. In particularly large and complicated migrations and refactorings, this can be useful to update post-migration files so that any fixes that need to be immediately applied can be. This can be a bit of a time-saver if you have to manually test and re-test the migration operation several times to be absolutely sure that the code is in a usable state after the migration.
|
54
|
+
|
55
|
+
Running <code>perforce2svn --help</code> should give you most of the details that may be missing here.
|
56
|
+
|
57
|
+
## Hacking
|
58
|
+
|
59
|
+
This tool is not the most refined. It doesn't handle branches and merges, really at all. It's probably better than the Perl tool (since you can see what's actually happenning), but it can still be a bit difficult to maintain full backward history.
|
60
|
+
|
61
|
+
If you want to add that functionality and take this project from me, go for it! I created an older version of this tool some time ago, and it should really be extended by somebody with more insight into Perforce's internals than myself. There are at least a few tests to see what you can do, and several of the tricky parts of the SVN API have been carefully masked for most operations.
|
62
|
+
|
63
|
+
In fact, you could take some of the work here and create other Perforce migration clients, since you can pull out file contents fairly easily.
|
64
|
+
|
65
|
+
May the Source be With You.
|
66
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __FILE__)
|
2
|
+
$LOAD_PATH.unshift File.expand_path("../spec", __FILE__)
|
3
|
+
|
4
|
+
require 'fileutils'
|
5
|
+
require 'rake'
|
6
|
+
require 'rubygems'
|
7
|
+
require 'rspec/core/rake_task'
|
8
|
+
require 'choosy/rake'
|
9
|
+
|
10
|
+
task :default => :spec
|
11
|
+
task :test => [:integration, :spec]
|
12
|
+
|
13
|
+
desc "Run the RSpec tests for the main perforce2svn tree"
|
14
|
+
RSpec::Core::RakeTask.new(:spec) do |t|
|
15
|
+
t.pattern = 'spec/perforce2svn/**/*_spec.rb'
|
16
|
+
end
|
17
|
+
|
18
|
+
desc "Runs the integration tests"
|
19
|
+
RSpec::Core::RakeTask.new(:integration) do |t|
|
20
|
+
t.pattern = 'spec/integration/**/*_spec.rb'
|
21
|
+
end
|
22
|
+
|
23
|
+
desc "Clean up"
|
24
|
+
task :clean => ['gem:clean']
|
data/bin/perforce2svn
ADDED
data/lib/VERSION.yml
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
require 'perforce2svn/errors'
|
2
|
+
require 'perforce2svn/version_range'
|
3
|
+
require 'perforce2svn/mapping/mapping_file'
|
4
|
+
require 'perforce2svn/migrator'
|
5
|
+
require 'choosy'
|
6
|
+
|
7
|
+
module Perforce2Svn
|
8
|
+
class CLI
|
9
|
+
include Choosy::Terminal
|
10
|
+
|
11
|
+
def execute!(args)
|
12
|
+
command.execute!(args)
|
13
|
+
end
|
14
|
+
|
15
|
+
def parse!(args, propagate=false)
|
16
|
+
command.parse!(args, propagate)
|
17
|
+
end
|
18
|
+
|
19
|
+
def command
|
20
|
+
ctx = self
|
21
|
+
version_info = Choosy::Version.load_from_parent
|
22
|
+
Choosy::Command.new :perforce2svn do
|
23
|
+
printer :manpage,
|
24
|
+
:version => version_info.to_s,
|
25
|
+
:date => version_info.date,
|
26
|
+
:manual => 'Perforce2Svn'
|
27
|
+
|
28
|
+
executor do |args, options|
|
29
|
+
migrator = Migrator.new(args[0], options)
|
30
|
+
migrator.run!
|
31
|
+
end
|
32
|
+
|
33
|
+
summary 'Migrates Perforce repository files into Subversion'
|
34
|
+
|
35
|
+
section 'DESCRIPTION' do
|
36
|
+
para 'This is a migration tool for migrating specific branches in Perforce into Subversion. It uses a mapping file to define the branch mappings at the directory level.'
|
37
|
+
para 'Because these migrations can be quite complex, and involve more sophisticated translations, this mapping file also allows for much more sophisticated operations on the Subversion repository after the migration, at least somewhat mitigating the difficulties in doing complex transformations in a unified way.'
|
38
|
+
para 'This utility assumes that you have already logged into Perforce and that the P4USER and P4PORT environment variables are set correctly.'
|
39
|
+
end
|
40
|
+
|
41
|
+
section 'OPTIONS' do
|
42
|
+
string :repository, "The path to the SVN repository. Required." do
|
43
|
+
required
|
44
|
+
depends_on :mapping_file # So that the mapping file will get printed and exit before this option gets validated
|
45
|
+
end
|
46
|
+
string :live_path, "The path to the files you want to add or update." do
|
47
|
+
validate do |args, options|
|
48
|
+
if !File.directory?(options[:live_path])
|
49
|
+
die "The --live-path must be a directory: #{options[:live_path]}"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
string :changes, "The revision range to import from. This has the format START:END where START >= 1 and END can be any number or 'HEAD'." do
|
54
|
+
validate do |args, options|
|
55
|
+
options[:changes] = VersionRange.build(options[:changes])
|
56
|
+
end
|
57
|
+
end
|
58
|
+
boolean :skip_commands, "Skip the embedded commands in the configuration that are run after the perforce migration.", :short => '-u'
|
59
|
+
boolean :skip_perforce, "Skip the perforce migration, and run only the embedded commands.", :short => '-p'
|
60
|
+
boolean :analyze_only, "Only analyzes your mapping files for possible errors, but does not attempt to run the migration."
|
61
|
+
|
62
|
+
# Informative
|
63
|
+
para
|
64
|
+
boolean :debug, "Prints extra debug information"
|
65
|
+
version version_info.to_s
|
66
|
+
boolean :mapping_file, "Shows a detailed mapping file example" do
|
67
|
+
validate do |args, options|
|
68
|
+
ctx.page(Mapping::MappingFile.help_file)
|
69
|
+
exit 0
|
70
|
+
end
|
71
|
+
end
|
72
|
+
help
|
73
|
+
end
|
74
|
+
|
75
|
+
section 'FILES' do
|
76
|
+
para "Rather than define all of the file path mappings between the old Perforce repository and the new Subversion repository, this tool requires you to define a mapping file."
|
77
|
+
para "This file contains all of the relevant commands for migrating a complicated set of paths and branches from Perforce into Subversion."
|
78
|
+
para "The mapping file has an explicit syntax. Each line contains a directive and a set of arguments. Each argument is separated by a space, though that space can be escaped with a '\\' character. Lines can have comments starting with the '#' character and they continue to the end of the line."
|
79
|
+
para "Please use the '--mapping-help' command for more detailed information."
|
80
|
+
end
|
81
|
+
|
82
|
+
section 'ENVIRONMENT' do
|
83
|
+
para "P4USER - The username used to connect to the Perforce server."
|
84
|
+
para "P4PORT - The Perforce server name."
|
85
|
+
para "svnadmin - This tool must be installed."
|
86
|
+
para "p4 - The Perforce command line utility must be installed."
|
87
|
+
end
|
88
|
+
|
89
|
+
section 'BUGS AND LIMITATIONS' do
|
90
|
+
para "While this tool works better than the other, Perl-based tool, it has the same kind of limitations. Namely, it cannot track certain file changes well (like copying or moving). That requires information that isn't readily accessible."
|
91
|
+
para "Also, you may notice that some files are not exactly the same after the migration. The p4 utility occasionally adds newline characters at the end of the file stream, for inexplicable reasons, so sometimes there is an extra newline at the end of some text files. There's really no way around it."
|
92
|
+
end
|
93
|
+
|
94
|
+
section "LICENSE" do
|
95
|
+
para "This software is licensed under the MIT license. No warranty is expressed or implied by this software's use."
|
96
|
+
para "This software shall be used for Good, not Evil."
|
97
|
+
end
|
98
|
+
|
99
|
+
section "AUTHOR" do
|
100
|
+
para "Gabe McArthur <madeonamac@gmail.com>"
|
101
|
+
para "Submit fixes to: https://github.com/gabemc/perforce2svn"
|
102
|
+
end
|
103
|
+
|
104
|
+
arguments do
|
105
|
+
count 1
|
106
|
+
metaname 'MAPPING_FILE'
|
107
|
+
|
108
|
+
validate do |args, options|
|
109
|
+
if !File.file?(args[0])
|
110
|
+
die "the mapping file doesn't exist: #{args[0]}"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'choosy/terminal'
|
2
|
+
require 'perforce2svn/perforce/p4_depot'
|
3
|
+
|
4
|
+
module Perforce2Svn
|
5
|
+
class Environment
|
6
|
+
include Choosy::Terminal
|
7
|
+
|
8
|
+
def check!
|
9
|
+
check_svnadmin
|
10
|
+
check_svnlib
|
11
|
+
check_perforce
|
12
|
+
check_p4lib
|
13
|
+
check_p4_liveness
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
def check_svnadmin
|
18
|
+
if !command_exists?('svnadmin')
|
19
|
+
die "Unable to locate svnadmin"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def check_svnlib
|
24
|
+
begin
|
25
|
+
require 'svn/core'
|
26
|
+
rescue LoadError
|
27
|
+
die "Unable to locate the native subversion bindings. Please install."
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def check_perforce
|
32
|
+
user = check_env('P4USER')
|
33
|
+
server = check_env('P4PORT')
|
34
|
+
|
35
|
+
if !system('p4 help > /dev/null 2>&1')
|
36
|
+
die "Unable to locate or execute the 'p4' command. Is it on the PATH? Are you logged in?"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def check_env(name)
|
41
|
+
value = ENV[name]
|
42
|
+
if value.nil? || value.empty?
|
43
|
+
die "Unable to locate the '#{name}' environment variable"
|
44
|
+
end
|
45
|
+
value
|
46
|
+
end
|
47
|
+
|
48
|
+
def check_p4lib
|
49
|
+
begin
|
50
|
+
require 'P4'
|
51
|
+
if P4.identify =~ /\((\d+.\d+) API\)/
|
52
|
+
maj, min = $1.split(/\./)
|
53
|
+
if maj.to_i < 2009
|
54
|
+
die "Requires a P4 library version >= 2009.2"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
rescue LoadError
|
58
|
+
die 'Unable to locate the P4 library, please install p4ruby'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def check_p4_liveness
|
63
|
+
Perforce::P4Depot.instance.connect!
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
|
2
|
+
module Perforce2Svn
|
3
|
+
Error = Class.new(RuntimeError)
|
4
|
+
|
5
|
+
# Configuration
|
6
|
+
ConfigurationError = Class.new(Perforce2Svn::Error)
|
7
|
+
|
8
|
+
# Subversion errors
|
9
|
+
SvnTransactionError = Class.new(Perforce2Svn::Error)
|
10
|
+
|
11
|
+
# Perforce
|
12
|
+
P4Error = Class.new(Perforce2Svn::Error)
|
13
|
+
|
14
|
+
# Mappings
|
15
|
+
MappingParserError = Class.new(Perforce2Svn::Error)
|
16
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'log4r'
|
2
|
+
|
3
|
+
module Perforce2Svn
|
4
|
+
# A convenient module for mixins that allow to
|
5
|
+
# share the logging configuration everywhere
|
6
|
+
# easily
|
7
|
+
module Logging
|
8
|
+
@@log = nil
|
9
|
+
|
10
|
+
def self.configure(debug)
|
11
|
+
if @@log.nil?
|
12
|
+
@@log = Log4r::Logger.new 'perforce2svn'
|
13
|
+
@@log.outputters = Log4r::Outputter.stdout
|
14
|
+
@@log.level = if ENV['RSPEC_RUNNING']
|
15
|
+
Log4r::FATAL
|
16
|
+
elsif debug
|
17
|
+
Log4r::DEBUG
|
18
|
+
else
|
19
|
+
Log4r::INFO
|
20
|
+
end
|
21
|
+
Log4r::Outputter.stdout.formatter = Log4r::PatternFormatter.new(:pattern => "[%l]\t%M")
|
22
|
+
end
|
23
|
+
@@log
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.log
|
27
|
+
@@log ||= configure(true)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Meant for mixing into other classes for simplified logging
|
31
|
+
def log
|
32
|
+
@@log ||= Logging.log
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'perforce2svn/logging'
|
2
|
+
|
3
|
+
module Perforce2Svn::Mapping
|
4
|
+
class Analyzer
|
5
|
+
include Perforce2Svn::Logging
|
6
|
+
|
7
|
+
def initialize(base_path)
|
8
|
+
@base_path = base_path
|
9
|
+
end
|
10
|
+
|
11
|
+
def check(commands)
|
12
|
+
# TODO: May want to make this more robust, like checking perforce paths and overlaps
|
13
|
+
succeeded = true
|
14
|
+
commands.each do |command|
|
15
|
+
if command.respond_to? :live_path
|
16
|
+
path = command.live_path
|
17
|
+
if path !~ /^\//
|
18
|
+
path = File.join(@base_path, path)
|
19
|
+
end
|
20
|
+
|
21
|
+
unless File.file? path
|
22
|
+
log.error("(line #{command.line_number}) The live path doesn't exist: #{command.live_path}")
|
23
|
+
succeeded = false
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
succeeded
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|