dandelion 0.3.15 → 0.4.0.beta2

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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +1 -0
  3. data/.travis.yml +5 -0
  4. data/Gemfile +11 -0
  5. data/LICENSE +1 -1
  6. data/README.md +15 -26
  7. data/Rakefile +4 -8
  8. data/bin/dandelion +4 -3
  9. data/dandelion.gemspec +23 -20
  10. data/lib/dandelion.rb +26 -10
  11. data/lib/dandelion/adapter.rb +45 -0
  12. data/lib/dandelion/{backend → adapter}/ftp.rb +22 -17
  13. data/lib/dandelion/adapter/noop.rb +14 -0
  14. data/lib/dandelion/{backend → adapter}/s3.rb +14 -15
  15. data/lib/dandelion/{backend → adapter}/sftp.rb +29 -16
  16. data/lib/dandelion/change.rb +11 -0
  17. data/lib/dandelion/changeset.rb +49 -0
  18. data/lib/dandelion/cli.rb +127 -0
  19. data/lib/dandelion/command.rb +26 -92
  20. data/lib/dandelion/command/deploy.rb +54 -41
  21. data/lib/dandelion/command/status.rb +14 -13
  22. data/lib/dandelion/config.rb +36 -0
  23. data/lib/dandelion/deployer.rb +53 -0
  24. data/lib/dandelion/diff.rb +62 -0
  25. data/lib/dandelion/tree.rb +22 -0
  26. data/lib/dandelion/utils.rb +13 -0
  27. data/lib/dandelion/version.rb +2 -2
  28. data/lib/dandelion/workspace.rb +71 -0
  29. data/spec/dandelion/adapter_spec.rb +42 -0
  30. data/spec/dandelion/change_spec.rb +17 -0
  31. data/spec/dandelion/changeset_spec.rb +73 -0
  32. data/spec/dandelion/command/deploy_spec.rb +111 -0
  33. data/spec/dandelion/command/status_spec.rb +4 -0
  34. data/spec/dandelion/command_spec.rb +70 -0
  35. data/spec/dandelion/config_spec.rb +15 -0
  36. data/spec/dandelion/deployer_spec.rb +65 -0
  37. data/spec/dandelion/diff_spec.rb +54 -0
  38. data/spec/dandelion/tree_spec.rb +15 -0
  39. data/spec/dandelion/workspace_spec.rb +77 -0
  40. data/{test/test_git.git → spec/fixtures/repo.git}/HEAD +0 -0
  41. data/{test/test_git.git → spec/fixtures/repo.git}/config +1 -0
  42. data/{test/test_git.git → spec/fixtures/repo.git}/description +0 -0
  43. data/{test/test_git.git → spec/fixtures/repo.git}/hooks/applypatch-msg.sample +0 -0
  44. data/{test/test_git.git → spec/fixtures/repo.git}/hooks/commit-msg.sample +0 -0
  45. data/{test/test_git.git → spec/fixtures/repo.git}/hooks/post-update.sample +0 -0
  46. data/{test/test_git.git → spec/fixtures/repo.git}/hooks/pre-applypatch.sample +0 -0
  47. data/{test/test_git.git → spec/fixtures/repo.git}/hooks/pre-commit.sample +6 -2
  48. data/spec/fixtures/repo.git/hooks/pre-push.sample +53 -0
  49. data/{test/test_git.git → spec/fixtures/repo.git}/hooks/pre-rebase.sample +0 -0
  50. data/{test/test_git.git → spec/fixtures/repo.git}/hooks/prepare-commit-msg.sample +0 -0
  51. data/{test/test_git.git → spec/fixtures/repo.git}/hooks/update.sample +1 -1
  52. data/{test/test_git.git → spec/fixtures/repo.git}/info/exclude +0 -0
  53. data/spec/fixtures/repo.git/objects/25/7cc5642cb1a054f08cc83f2d943e56fd3ebe99 +0 -0
  54. data/spec/fixtures/repo.git/objects/34/e64fe350f3ea80c989cdac7a99c2adb8574fca +0 -0
  55. data/spec/fixtures/repo.git/objects/3d/9b743acb4a84dd99002d2c6f3fcf1a47e9f06b +0 -0
  56. data/spec/fixtures/repo.git/objects/4e/44973d41d33bf5342037f56497efe0a9604d25 +0 -0
  57. data/{test/test_git.git → spec/fixtures/repo.git}/objects/57/16ca5987cbf97d6bb54920bea6adde242d87e6 +0 -0
  58. data/spec/fixtures/repo.git/objects/6c/a0f54491390579ce9438ec89c64c6b3499683a +0 -0
  59. data/spec/fixtures/repo.git/objects/8a/e33865a630c5d141c8a498f0c0166ff240b433 +0 -0
  60. data/{test/test_git.git → spec/fixtures/repo.git}/objects/90/2dce0535b19f0c15ac8407fc4468256ad672d7 +0 -0
  61. data/spec/fixtures/repo.git/objects/c3/9af82404cc4267b1ba5f4b4437a511e0776abb +0 -0
  62. data/spec/fixtures/repo.git/objects/c8/85b3f693ed6e2926971ef75680f41b318072ae +0 -0
  63. data/spec/fixtures/repo.git/objects/d8/7cbcba0e2ede0752bdafc5938da35546803ba5 +0 -0
  64. data/spec/fixtures/repo.git/objects/e2/89ff1e2729839759dbd6fe99b6e35880910c7c +0 -0
  65. data/{test/test_git.git → spec/fixtures/repo.git}/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
  66. data/{test/test_git.git → spec/fixtures/repo.git}/objects/ea/41dba10b54a794284e0be009a11f0ff3716a28 +0 -0
  67. data/spec/fixtures/repo.git/objects/ee/314a31b622b027c10981acaed7903a3607dbd4 +0 -0
  68. data/spec/fixtures/repo.git/objects/f6/66b137794d56880bab05e8fd256713a8fccf92 +0 -0
  69. data/spec/fixtures/repo.git/refs/heads/master +1 -0
  70. data/spec/spec_helper.rb +28 -0
  71. metadata +119 -127
  72. data/lib/dandelion/application.rb +0 -73
  73. data/lib/dandelion/backend.rb +0 -54
  74. data/lib/dandelion/deployment.rb +0 -173
  75. data/lib/dandelion/git.rb +0 -123
  76. data/test/fixtures/diff +0 -3
  77. data/test/fixtures/ls_tree +0 -5
  78. data/test/test_diff_deployment.rb +0 -122
  79. data/test/test_ftp.rb +0 -49
  80. data/test/test_git.git/hooks/post-commit.sample +0 -8
  81. data/test/test_git.git/hooks/post-receive.sample +0 -15
  82. data/test/test_git.git/objects/0c/a605e9f0f1d42ce8193ac36db11ec3cc9efc08 +0 -0
  83. data/test/test_git.git/objects/11/bada4e36fd065c8d1d3ca97b8dffa496c8e021 +0 -0
  84. data/test/test_git.git/objects/88/d4480861346093048e08ce8dcc577d8aa69379 +0 -1
  85. data/test/test_git.git/objects/a6/394b3e8a82b76b0dd5b6b317f489dfe22426a6 +0 -0
  86. data/test/test_git.git/objects/a6/5140d5ec9f47064f614ecf8e43776baa5c0c11 +0 -0
  87. data/test/test_git.git/objects/f5/5f3c44c89e5d215fbaaef9d33563117fe0b61b +0 -1
  88. data/test/test_git.git/objects/ff/1f1d4bd0c99e1c9cca047c46b2194accf89504 +0 -4
  89. data/test/test_git.git/refs/heads/master +0 -1
  90. data/test/test_git.rb +0 -50
  91. data/test/test_sftp.rb +0 -54
@@ -1,20 +1,21 @@
1
- require 'dandelion/backend'
2
1
  require 'pathname'
2
+ require 'dandelion/utils'
3
3
 
4
4
  module Dandelion
5
- module Backend
6
- class SFTP < Backend::Base
7
- scheme 'sftp'
8
- gems 'net-sftp'
5
+ module Adapter
6
+ class SFTP < Adapter::Base
7
+ include ::Dandelion::Utils
8
+
9
+ adapter 'sftp'
10
+ requires_gems 'net-sftp'
9
11
 
10
12
  def initialize(config)
11
13
  require 'net/sftp'
12
- @config = { 'preserve_permissions' => true }.merge(config)
13
- options = {
14
- :password => @config['password'],
15
- :port => @config['port'] || Net::SSH::Transport::Session::DEFAULT_PORT,
16
- }
17
- @sftp = Net::SFTP.start(@config['host'], @config['username'], options)
14
+
15
+ @config = config
16
+ @config.defaults(preserve_permissions: true)
17
+
18
+ @sftp = sftp_client
18
19
  end
19
20
 
20
21
  def read(file)
@@ -24,7 +25,7 @@ module Dandelion
24
25
  end
25
26
  rescue Net::SFTP::StatusException => e
26
27
  raise unless e.code == 2
27
- raise MissingFileError
28
+ nil
28
29
  end
29
30
  end
30
31
 
@@ -38,9 +39,10 @@ module Dandelion
38
39
  @sftp.upload!(temp, path(file))
39
40
  end
40
41
  end
41
- if @config['preserve_permissions']
42
+
43
+ if @config[:preserve_permissions]
42
44
  mode = get_mode(file)
43
- @sftp.setstat!(path(file), :permissions => mode) if mode
45
+ @sftp.setstat!(path(file), permissions: mode) if mode
44
46
  end
45
47
  end
46
48
 
@@ -59,6 +61,15 @@ module Dandelion
59
61
 
60
62
  private
61
63
 
64
+ def sftp_client
65
+ options = {
66
+ password: @config['password'],
67
+ port: @config['port'] || Net::SSH::Transport::Session::DEFAULT_PORT,
68
+ }
69
+
70
+ Net::SFTP.start(@config['host'], @config['username'], options)
71
+ end
72
+
62
73
  def get_mode(file)
63
74
  stat = File.stat(file) if File.exists?(file)
64
75
  stat.mode if stat
@@ -78,7 +89,9 @@ module Dandelion
78
89
  end
79
90
 
80
91
  def empty?(dir)
81
- @sftp.dir.entries(dir).delete_if { |file| file.name == '.' or file.name == '..' }.empty?
92
+ @sftp.dir.entries(dir).delete_if do |file|
93
+ file.name == '.' or file.name == '..'
94
+ end.empty?
82
95
  end
83
96
 
84
97
  def mkdir_p(dir)
@@ -100,4 +113,4 @@ module Dandelion
100
113
  end
101
114
  end
102
115
  end
103
- end
116
+ end
@@ -0,0 +1,11 @@
1
+ module Dandelion
2
+ class Change
3
+ attr_reader :path, :type, :data
4
+
5
+ def initialize(path, type, data = nil)
6
+ @path = path
7
+ @type = type
8
+ @data = data
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,49 @@
1
+ module Dandelion
2
+ class Changeset
3
+ include Enumerable
4
+
5
+ def initialize(tree, commit, options = {})
6
+ @tree = tree
7
+ @commit = commit
8
+ @options = options
9
+ end
10
+
11
+ def diff
12
+ Diff.new(@commit, @tree.commit)
13
+ end
14
+
15
+ def empty?
16
+ diff.empty?
17
+ end
18
+
19
+ def each
20
+ diff.each do |change|
21
+ if applicable?(change.path)
22
+ path = transform_path(change.path)
23
+
24
+ if change.type == :delete
25
+ yield Change.new(path, change.type)
26
+ else
27
+ yield Change.new(path, change.type, @tree.data(change.path))
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def local_path
36
+ @options[:local_path] || ''
37
+ end
38
+
39
+ def applicable?(path)
40
+ path.start_with?(local_path)
41
+ end
42
+
43
+ def transform_path(path)
44
+ trimmed = path[local_path.length..-1]
45
+ trimmed = trimmed[1..-1] if trimmed[0] == File::SEPARATOR
46
+ trimmed
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,127 @@
1
+ require 'optparse'
2
+
3
+ module Dandelion
4
+ class CLI
5
+ def initialize(args)
6
+ @args = args
7
+ @options = {}
8
+ @parser = Command::Base.parser(@options)
9
+ end
10
+
11
+ def config
12
+ @config ||= Config.new(path: config_path).tap do |config|
13
+ config[:adapter] ||= config[:scheme] # backward compat
14
+ end
15
+ end
16
+
17
+ def adapter
18
+ @adapter ||= Adapter::Base.create_adapter(config[:adapter], config)
19
+ rescue Adapter::InvalidAdapterError => e
20
+ log.fatal("Unsupported adapter: #{config[:adapter]}")
21
+ exit 1
22
+ rescue Adapter::MissingDependencyError => e
23
+ log.fatal("The #{config[:adapter]} adapter requires additional gems:")
24
+ log.fatal(e.gems.map { |name| " #{name}"}.join("\n"))
25
+ log.fatal("Please install the gems first: gem install #{e.gems.join(' ')}")
26
+ exit 1
27
+ end
28
+
29
+ def repo
30
+ @repo ||= Rugged::Repository.new(repo_path)
31
+ end
32
+
33
+ def workspace
34
+ @workspace ||= Workspace.new(repo, adapter, config)
35
+ end
36
+
37
+ def command_class
38
+ @command_class ||= Command::Base.lookup(@args.shift.to_sym)
39
+ rescue Command::InvalidCommandError => e
40
+ log.fatal("Invalid command: #{e}")
41
+ display_help
42
+ exit 1
43
+ end
44
+
45
+ def execute!
46
+ parse!(@parser)
47
+
48
+ if @options[:help]
49
+ display_help
50
+ exit
51
+ end
52
+
53
+ if @options[:version]
54
+ log.info("Dandelion #{Dandelion::VERSION}")
55
+ exit
56
+ end
57
+
58
+ validate!
59
+
60
+ parse!(command_class.parser(@options))
61
+
62
+ command = command_class.new(workspace, config, @options)
63
+ command.setup(@args)
64
+
65
+ begin
66
+ command.execute!
67
+ rescue RevisionError => e
68
+ log.fatal("Invalid revision: #{e}")
69
+ exit 1
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def config_path
76
+ @options[:config] || File.join(repo_path, 'dandelion.yml')
77
+ end
78
+
79
+ def repo_path
80
+ if @options[:repo]
81
+ File.expand_path(@options[:repo])
82
+ else
83
+ closest_repo(File.expand_path('.'))
84
+ end
85
+ end
86
+
87
+ def validate!
88
+ unless File.exists?(File.join(repo_path, '.git'))
89
+ log.fatal("Not a git repository: #{repo_path}")
90
+ exit 1
91
+ end
92
+
93
+ unless File.exists?(config_path)
94
+ log.fatal("Missing config file: #{config_path}")
95
+ exit 1
96
+ end
97
+ end
98
+
99
+ def parse!(parser)
100
+ begin
101
+ parser.order!(@args)
102
+ rescue OptionParser::InvalidOption => e
103
+ log.fatal(e.to_s.capitalize)
104
+ display_help
105
+ exit 1
106
+ end
107
+ end
108
+
109
+ def closest_repo(dir)
110
+ if File.exists?(File.join(dir, '.git'))
111
+ dir
112
+ else
113
+ File.dirname(dir) != dir && closest_repo(File.dirname(dir)) || File.expand_path('.')
114
+ end
115
+ end
116
+
117
+ def display_help
118
+ log.info(@parser.help)
119
+ log.info("Available commands:")
120
+ log.info(Command::Base.commands.map { |c| " #{c}"}.join("\n"))
121
+ end
122
+
123
+ def log
124
+ Dandelion.logger
125
+ end
126
+ end
127
+ end
@@ -1,10 +1,4 @@
1
- require 'dandelion'
2
- require 'dandelion/backend'
3
- require 'dandelion/deployment'
4
- require 'dandelion/git'
5
- require 'dandelion/version'
6
1
  require 'optparse'
7
- require 'yaml'
8
2
 
9
3
  module Dandelion
10
4
  module Command
@@ -18,123 +12,63 @@ module Dandelion
18
12
  @@commands[name] = self
19
13
  end
20
14
 
21
- def create(name)
22
- require_commands
23
- raise InvalidCommandError unless @@commands.include?(name)
24
- @@commands[name]
25
- end
26
-
27
15
  def commands
28
16
  @@commands.keys
29
17
  end
30
18
 
31
- def require_commands
32
- Dir.glob(File.join(File.dirname(__FILE__), 'command', '*.rb')) { |file| require file }
19
+ def lookup(name)
20
+ raise InvalidCommandError.new(name) unless @@commands[name]
21
+ @@commands[name]
33
22
  end
34
23
 
35
24
  def parser(options)
36
25
  OptionParser.new do |opts|
37
26
  opts.banner = 'Usage: dandelion [options] <command> [<args>]'
38
27
 
39
- opts.on('-v', '--version', 'Display the current version') do
40
- puts "Dandelion #{Dandelion::VERSION}"
41
- exit
28
+ options[:version] = false
29
+ opts.on('-v', '--version', 'Dispay the current version') do
30
+ options[:version] = true
42
31
  end
43
32
 
44
- opts.on('-h', '--help', 'Display this screen') do
45
- require_commands
46
- puts opts
47
- puts "\nAvailable commands:"
48
- puts commands.map { |name| " #{name}" }.join("\n")
49
- exit
33
+ options[:help] = false
34
+ opts.on('-h', '--help', 'Display this help info') do
35
+ options[:help] = true
50
36
  end
51
37
 
52
- opts.on('--log=[level]', 'Use the given log level (fatal, error, warn, info, debug)') do |level|
53
- levels = {
54
- :fatal => Logger::FATAL,
55
- :error => Logger::ERROR,
56
- :warn => Logger::WARN,
57
- :info => Logger::INFO,
58
- :debug => Logger::DEBUG
59
- }
60
-
61
- Dandelion.logger.level = levels[level.to_sym]
62
- end
63
-
64
- options[:repo] = closest_repo(File.expand_path('.'))
38
+ options[:repo] = nil
65
39
  opts.on('--repo=[REPO]', 'Use the given repository') do |repo|
66
- options[:repo] = File.expand_path(repo)
40
+ options[:repo] = repo
67
41
  end
68
42
 
69
43
  options[:config] = nil
70
- opts.on('--config=[CONFIG]', 'Use the given configuration file') do |config|
71
- options[:config] = File.expand_path(config)
44
+ opts.on('--config=[CONFIG]', 'Use the given config file') do |config|
45
+ options[:config] = config
72
46
  end
73
47
  end
74
48
  end
75
-
76
- private
77
-
78
- def closest_repo(dir)
79
- if File.exists?(File.join(dir, '.git'))
80
- dir
81
- else
82
- File.dirname(dir) != dir && closest_repo(File.dirname(dir)) || File.expand_path('.')
83
- end
84
- end
85
49
  end
86
50
 
87
- def initialize(options)
88
- @options = options
89
- @config = YAML.load_file(@options[:config])
90
- @repo = Git::Repo.new(@options[:repo])
51
+ attr_reader :workspace, :config, :options
91
52
 
92
- yield(self) if block_given?
53
+ def initialize(workspace, config, options = {})
54
+ @workspace = workspace
55
+ @config = config
56
+ @options = options
93
57
  end
94
58
 
95
- protected
96
-
97
- def log
98
- Dandelion.logger
59
+ def setup(args)
99
60
  end
100
61
 
101
- def backend
102
- begin
103
- backend = Backend::Base.create(@config)
104
- log.info("Connecting to #{backend}")
105
- backend
106
- rescue Backend::MissingDependencyError => e
107
- log.fatal("The '#{@config['scheme']}' scheme requires additional gems:")
108
- log.fatal(e.gems.map { |name| " #{name}" }.join("\n"))
109
- log.fatal("Please install the gems: gem install #{e.gems.join(' ')}")
110
- exit 1
111
- rescue Backend::UnsupportedSchemeError
112
- log.fatal("Unsupported scheme: #{@config['scheme']}")
113
- exit 1
114
- end
62
+ def adapter
63
+ workspace.adapter
115
64
  end
116
65
 
117
- def deployment(revision, backend = nil)
118
- begin
119
- backend ||= backend()
120
- revision_file = @config['revision_file'].nil? ? '.revision' : @config['revision_file']
121
- local_path = @config['local_path'].nil? ? '' : @config['local_path']
122
- options = {
123
- :dry => @options[:dry],
124
- :exclude => @config['exclude'],
125
- :additional => @config['additional'],
126
- :revision => revision,
127
- :revision_file => revision_file,
128
- :local_path => local_path
129
- }
130
-
131
- Deployment::Deployment.create(@repo, backend, options)
132
- rescue Git::DiffError
133
- log.fatal('Error: could not generate diff')
134
- log.fatal('Try merging remote changes before running dandelion again')
135
- exit 1
136
- end
66
+ def log
67
+ Dandelion.logger
137
68
  end
138
69
  end
139
70
  end
140
71
  end
72
+
73
+ require 'dandelion/command/deploy'
74
+ require 'dandelion/command/status'
@@ -1,54 +1,67 @@
1
1
  module Dandelion
2
2
  module Command
3
3
  class Deploy < Command::Base
4
- command 'deploy'
5
-
6
- class << self
7
- def parser(options)
8
- OptionParser.new do |opts|
9
- opts.banner = 'Usage: deploy [options] [<revision>]'
10
-
11
- options[:force] = false
12
- opts.on('-f', '--force', 'Force deployment') do
13
- options[:force] = true
14
- end
15
-
16
- options[:dry] = false
17
- opts.on('--dry-run', 'Show what would have been deployed') do
18
- options[:dry] = true
19
- end
4
+ command :deploy
5
+
6
+ def self.parser(options)
7
+ OptionParser.new do |opts|
8
+ opts.banner = 'Usage: dandelion deploy [options] [<revision>]'
9
+
10
+ options[:dry] = false
11
+ opts.on('--dry-run', 'Show what would have been deployed') do
12
+ options[:dry] = true
20
13
  end
21
14
  end
22
15
  end
23
-
16
+
24
17
  def setup(args)
25
- @revision = args.shift || 'HEAD'
18
+ config[:revision] = args.shift || nil
26
19
  end
27
-
28
- def execute
29
- begin
30
- @deployment = deployment(@revision)
31
- rescue Git::RevisionError
32
- log.fatal("Invalid revision: #{@revision}")
33
- exit 1
34
- end
35
-
36
- log.info("Remote revision: #{@deployment.remote_revision || '---'}")
37
- log.info("Deploying revision: #{@deployment.local_revision}")
20
+
21
+ def execute!
22
+ log.info("Connecting to #{adapter.to_s}")
23
+
24
+ local_commit = workspace.local_commit
25
+ remote_commit = workspace.remote_commit
38
26
 
39
- begin
40
- @deployment.validate
41
- rescue Deployment::FastForwardError
42
- if !@options[:force]
43
- log.warn('Warning: you are trying to deploy unpushed commits')
44
- log.warn('This could potentially prevent others from being able to deploy')
45
- log.warn('If you are sure you want to do this, use the -f option to force deployment')
46
- exit 1
47
- end
27
+ log.info("Remote revision: #{remote_commit ? remote_commit.oid : '---'}")
28
+ log.info("Deploying revision: #{local_commit.oid}")
29
+
30
+ deploy_changeset!
31
+ deploy_additional_files!
32
+ end
33
+
34
+ def deployer_adapter
35
+ if options[:dry]
36
+ Adapter::NoOpAdapter.new(config)
37
+ else
38
+ adapter
39
+ end
40
+ end
41
+
42
+ def deployer
43
+ @deployer ||= Deployer.new(deployer_adapter, config)
44
+ end
45
+
46
+ private
47
+
48
+ def deploy_changeset!
49
+ changeset = workspace.changeset
50
+
51
+ if changeset.empty?
52
+ log.info("No changes to deploy")
53
+ else
54
+ log.info("Deploying changes...")
55
+ deployer.deploy_changeset!(workspace.changeset)
56
+ workspace.remote_commit = workspace.local_commit unless options[:dry]
57
+ end
58
+ end
59
+
60
+ def deploy_additional_files!
61
+ if config[:additional] && config[:additional].length > 0
62
+ log.info("Deploying additional files...")
63
+ deployer.deploy_files!(config[:additional])
48
64
  end
49
-
50
- @deployment.deploy
51
- log.info("Deployment complete")
52
65
  end
53
66
  end
54
67
  end