gitolite-rugged 1.2.pre.devel

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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/.gemtest +0 -0
  3. data/.gitignore +9 -0
  4. data/.travis.yml +14 -0
  5. data/Gemfile +5 -0
  6. data/Guardfile +13 -0
  7. data/LICENSE.txt +26 -0
  8. data/README.md +108 -0
  9. data/Rakefile +59 -0
  10. data/gitolite.gemspec +36 -0
  11. data/lib/gitolite/config/group.rb +62 -0
  12. data/lib/gitolite/config/repo.rb +107 -0
  13. data/lib/gitolite/config.rb +284 -0
  14. data/lib/gitolite/dirty_proxy.rb +32 -0
  15. data/lib/gitolite/gitolite_admin.rb +276 -0
  16. data/lib/gitolite/ssh_key.rb +103 -0
  17. data/lib/gitolite/version.rb +3 -0
  18. data/lib/gitolite.rb +10 -0
  19. data/spec/config_spec.rb +498 -0
  20. data/spec/dirty_proxy_spec.rb +66 -0
  21. data/spec/fixtures/configs/complicated-output.conf +72 -0
  22. data/spec/fixtures/configs/complicated.conf +311 -0
  23. data/spec/fixtures/configs/simple.conf +5 -0
  24. data/spec/fixtures/keys/bob+joe@test.zilla.com@desktop.pub +1 -0
  25. data/spec/fixtures/keys/bob-ins@zilla-site.com@desktop.pub +1 -0
  26. data/spec/fixtures/keys/bob.joe@test.zilla.com@desktop.pub +1 -0
  27. data/spec/fixtures/keys/bob.pub +1 -0
  28. data/spec/fixtures/keys/bob@desktop.pub +1 -0
  29. data/spec/fixtures/keys/bob@foo-bar.pub +1 -0
  30. data/spec/fixtures/keys/bob@zilla.com.pub +1 -0
  31. data/spec/fixtures/keys/bob@zilla.com@desktop.pub +1 -0
  32. data/spec/fixtures/keys/jakub123.pub +1 -0
  33. data/spec/fixtures/keys/jakub123@foo.net.pub +1 -0
  34. data/spec/fixtures/keys/joe-bob@god-zilla.com@desktop.pub +1 -0
  35. data/spec/fixtures/keys/joe@sch.ool.edu.pub +1 -0
  36. data/spec/fixtures/keys/joe@sch.ool.edu@desktop.pub +1 -0
  37. data/spec/gitolite_admin_spec.rb +40 -0
  38. data/spec/group_spec.rb +125 -0
  39. data/spec/repo_spec.rb +202 -0
  40. data/spec/spec_helper.rb +21 -0
  41. data/spec/ssh_key_spec.rb +355 -0
  42. metadata +280 -0
@@ -0,0 +1,284 @@
1
+ require 'tempfile'
2
+
3
+ module Gitolite
4
+
5
+ class Config
6
+
7
+ attr_accessor :repos, :groups, :filename
8
+
9
+ def self.init(filename = "gitolite.conf")
10
+ file = Tempfile.new(filename)
11
+ conf = self.new(file.path)
12
+ conf.filename = filename #kill suffix added by Tempfile
13
+ file.close(unlink_now = true)
14
+ conf
15
+ end
16
+
17
+
18
+ def initialize(config)
19
+ @repos = {}
20
+ @groups = {}
21
+ @filename = File.basename(config)
22
+ process_config(config)
23
+ end
24
+
25
+
26
+ # TODO: merge repo unless overwrite = true
27
+ def add_repo(repo, overwrite = false)
28
+ raise ArgumentError, "Repo must be of type Gitolite::Config::Repo!" unless repo.instance_of? Gitolite::Config::Repo
29
+ @repos[repo.name] = repo
30
+ end
31
+
32
+
33
+ def rm_repo(repo)
34
+ name = normalize_repo_name(repo)
35
+ @repos.delete(name)
36
+ end
37
+
38
+
39
+ def has_repo?(repo)
40
+ name = normalize_repo_name(repo)
41
+ @repos.has_key?(name)
42
+ end
43
+
44
+
45
+ def get_repo(repo)
46
+ name = normalize_repo_name(repo)
47
+ @repos[name]
48
+ end
49
+
50
+
51
+ def add_group(group, overwrite = false)
52
+ raise ArgumentError, "Group must be of type Gitolite::Config::Group!" unless group.instance_of? Gitolite::Config::Group
53
+ @groups[group.name] = group
54
+ end
55
+
56
+
57
+ def rm_group(group)
58
+ name = normalize_group_name(group)
59
+ @groups.delete(name)
60
+ end
61
+
62
+
63
+ def has_group?(group)
64
+ name = normalize_group_name(group)
65
+ @groups.has_key?(name)
66
+ end
67
+
68
+
69
+ def get_group(group)
70
+ name = normalize_group_name(group)
71
+ @groups[name]
72
+ end
73
+
74
+
75
+ def to_file(path=".", filename=@filename)
76
+ raise ArgumentError, "Path contains a filename or does not exist" unless File.directory?(path)
77
+
78
+ new_conf = File.join(path, filename)
79
+ File.open(new_conf, "w") do |f|
80
+ f.sync = true
81
+
82
+ # Output groups
83
+ dep_order = build_groups_depgraph
84
+ dep_order.each {|group| f.write group.to_s }
85
+
86
+ gitweb_descs = []
87
+ @repos.sort.each do |k, v|
88
+ f.write "\n"
89
+ f.write v.to_s
90
+
91
+ gwd = v.gitweb_description
92
+ gitweb_descs.push(gwd) unless gwd.nil?
93
+ end
94
+
95
+ f.write "\n"
96
+ f.write gitweb_descs.join("\n")
97
+ end
98
+
99
+ new_conf
100
+ end
101
+
102
+
103
+ private
104
+
105
+
106
+ # Based on
107
+ # https://github.com/sitaramc/gitolite/blob/pu/src/gl-compile-conf#cleanup_conf_line
108
+ def cleanup_config_line(line)
109
+ # remove comments, even those that happen inline
110
+ line.gsub!(/^((".*?"|[^#"])*)#.*/) {|m| m=$1}
111
+
112
+ # fix whitespace
113
+ line.gsub!('=', ' = ')
114
+ line.gsub!(/\s+/, ' ')
115
+ line.strip
116
+ end
117
+
118
+
119
+ def process_config(config)
120
+ context = [] #will store our context for permissions or config declarations
121
+
122
+ #Read each line of our config
123
+ File.open(config, 'r').each do |l|
124
+
125
+ line = cleanup_config_line(l)
126
+ next if line.empty? #lines are empty if we killed a comment
127
+
128
+ case line
129
+
130
+ # found a repo definition
131
+ when /^repo (.*)/
132
+ #Empty our current context
133
+ context = []
134
+
135
+ repos = $1.split
136
+ repos.each do |r|
137
+ context << r
138
+
139
+ @repos[r] = Repo.new(r) unless has_repo?(r)
140
+ end
141
+
142
+ # repo permissions
143
+ when /^(-|C|R|RW\+?(?:C?D?|D?C?)M?) (.* )?= (.+)/
144
+ perm = $1
145
+ refex = $2 || ""
146
+ users = $3.split
147
+
148
+ context.each do |c|
149
+ @repos[c].add_permission(perm, refex, users)
150
+ end
151
+
152
+ # repo git config
153
+ when /^config (.+) = ?(.*)/
154
+ key = $1
155
+ value = $2
156
+
157
+ context.each do |c|
158
+ @repos[c].set_git_config(key, value)
159
+ end
160
+
161
+ # repo gitolite option
162
+ when /^option (.+) = (.*)/
163
+ key = $1
164
+ value = $2
165
+
166
+ raise ParseError, "Missing gitolite option value for repo: #{repo} and key: #{key}" if value.nil?
167
+
168
+ context.each do |c|
169
+ @repos[c].set_gitolite_option(key, value)
170
+ end
171
+
172
+ # group definition
173
+ when /^#{Group::PREPEND_CHAR}(\S+) = ?(.*)/
174
+ group = $1
175
+ users = $2.split
176
+
177
+ @groups[group] = Group.new(group) unless has_group?(group)
178
+ @groups[group].add_users(users)
179
+
180
+ # gitweb definition
181
+ when /^(\S+)(?: "(.*?)")? = "(.*)"$/
182
+ repo = $1
183
+ owner = $2
184
+ description = $3
185
+
186
+ #Check for missing description
187
+ raise ParseError, "Missing Gitweb description for repo: #{repo}" if description.nil?
188
+
189
+ #Check for groups
190
+ raise ParseError, "Gitweb descriptions cannot be set for groups" if repo =~ /@.+/
191
+
192
+ if has_repo? repo
193
+ r = @repos[repo]
194
+ else
195
+ r = Repo.new(repo)
196
+ add_repo(r)
197
+ end
198
+
199
+ r.owner = owner
200
+ r.description = description
201
+
202
+ when /^include "(.+)"/
203
+ #TODO: implement includes
204
+ #ignore includes for now
205
+
206
+ when /^subconf (\S+)$/
207
+ #TODO: implement subconfs
208
+ #ignore subconfs for now
209
+
210
+ else
211
+ raise ParseError, "'#{line}' cannot be processed"
212
+ end
213
+ end
214
+ end
215
+
216
+
217
+ # Normalizes the various different input objects to Strings
218
+ def normalize_name(context, constant = nil)
219
+ case context
220
+ when constant
221
+ context.name
222
+ when Symbol
223
+ context.to_s
224
+ else
225
+ context
226
+ end
227
+ end
228
+
229
+
230
+ def method_missing(meth, *args, &block)
231
+ if meth.to_s =~ /normalize_(\w+)_name/
232
+ # Could use Object.const_get to figure out the constant here
233
+ # but for only two cases, this is more readable
234
+ case $1
235
+ when "repo"
236
+ normalize_name(args[0], Gitolite::Config::Repo)
237
+ when "group"
238
+ normalize_name(args[0], Gitolite::Config::Group)
239
+ end
240
+ else
241
+ super
242
+ end
243
+ end
244
+
245
+
246
+ # Builds a dependency tree from the groups in order to ensure all groups
247
+ # are defined before they are used
248
+ def build_groups_depgraph
249
+ dp = ::GRATR::Digraph.new
250
+
251
+ # Add each group to the graph
252
+ @groups.each_value do |group|
253
+ dp.add_vertex! group
254
+
255
+ # Select group names from the users
256
+ subgroups = group.users.select {|u| u =~ /^#{Group::PREPEND_CHAR}.*$/}.map{|g| get_group g.gsub(Group::PREPEND_CHAR, '') }
257
+
258
+ subgroups.each do |subgroup|
259
+ dp.add_edge! subgroup, group
260
+ end
261
+ end
262
+
263
+ # Figure out if we have a good depedency graph
264
+ dep_order = dp.topsort
265
+
266
+ if dep_order.empty?
267
+ raise GroupDependencyError unless @groups.empty?
268
+ end
269
+
270
+ dep_order
271
+ end
272
+
273
+
274
+ #Raised when something in a config fails to parse properly
275
+ class ParseError < RuntimeError
276
+ end
277
+
278
+
279
+ # Raised when group dependencies cannot be suitably resolved for output
280
+ class GroupDependencyError < RuntimeError
281
+ end
282
+
283
+ end
284
+ end
@@ -0,0 +1,32 @@
1
+ module Gitolite
2
+
3
+ # Very simple proxy object for checking if the proxied object was modified
4
+ # since the last clean_up! method called. It works correctly only for objects
5
+ # with proper hash method!
6
+
7
+ class DirtyProxy < BasicObject
8
+
9
+ def initialize(target)
10
+ @target = target
11
+ clean_up!
12
+ end
13
+
14
+ def method_missing(method, *args, &block)
15
+ @target.send(method, *args, &block)
16
+ end
17
+
18
+ def respond_to?(symbol, include_private=false)
19
+ super || [:dirty?, :clean_up!].include?(symbol.to_sym)
20
+ end
21
+
22
+ def dirty?
23
+ @clean_hash != @target.hash
24
+ end
25
+
26
+ def clean_up!
27
+ @clean_hash = @target.hash
28
+ end
29
+
30
+ end
31
+
32
+ end
@@ -0,0 +1,276 @@
1
+ module Gitolite
2
+ class GitoliteAdmin
3
+
4
+ attr_accessor :repo
5
+
6
+ CONF_DIR = "conf"
7
+ KEY_DIR = "keydir"
8
+
9
+ CONFIG_FILE = "gitolite.conf"
10
+ CONFIG_PATH = File.join(CONF_DIR, "gitolite.conf")
11
+
12
+
13
+ # Default settings
14
+ DEFAULT_SETTINGS = {
15
+ # clone/push url settings
16
+ git_user: 'git',
17
+ hostname: 'localhost',
18
+
19
+ # Commit settings
20
+ author_name: 'gitolite-rugged gem',
21
+ author_email: 'gitolite-rugged@localhost',
22
+ commit_msg: 'Commited by the gitolite-rugged gem'
23
+ }
24
+
25
+ class << self
26
+
27
+ # Checks if the given path is a gitolite-admin repository
28
+ # A valid repository contains a conf folder, keydir folder,
29
+ # and a configuration file within the conf folder
30
+ def is_gitolite_admin_repo?(dir)
31
+ # First check if it is a git repository
32
+ begin
33
+ repo = Rugged::Repository.new(dir)
34
+ return false if repo.empty?
35
+ rescue Rugged::RepositoryError
36
+ return false
37
+ end
38
+
39
+ # Check if config file, key directory exist
40
+ [ File.join(dir, CONF_DIR), File.join(dir, KEY_DIR),
41
+ File.join(dir, CONFIG_PATH)
42
+ ].each { |f| return false unless File.exists?(f) }
43
+
44
+ true
45
+ end
46
+
47
+ def admin_url(settings)
48
+ [settings[:git_user], '@', settings[:host], '/gitolite-admin.git'].join
49
+ end
50
+ end
51
+
52
+ # Intialize with the path to
53
+ # the gitolite-admin repository
54
+ #
55
+ # Settings:
56
+ # :git_user: The git user to SSH to (:git_user@localhost:gitolite-admin.git), defaults to 'git'
57
+ # :private_key: The key file containing the private SSH key for :git_user
58
+ # :public_key: The key file containing the public SSH key for :git_user
59
+ # :host: Hostname for clone url. Defaults to 'localhost'
60
+ # The settings hash is forwarded to +GitoliteAdmin.new+ as options.
61
+ def initialize(path, settings = {})
62
+ @path = path
63
+ @settings = DEFAULT_SETTINGS.merge(settings)
64
+
65
+ # Ensure SSH key settings exist
66
+ @settings.fetch(:public_key)
67
+ @settings.fetch(:private_key)
68
+
69
+ # setup credentials
70
+ @credentials = Rugged::Credentials::SshKey.new(
71
+ username: settings[:git_user], publickey: settings[:public_key],
72
+ privatekey: settings[:private_key] )
73
+
74
+ @repo =
75
+ if self.class.is_gitolite_admin_repo?(path)
76
+ Rugged::Repository.new(path)
77
+ else
78
+ clone
79
+ end
80
+
81
+ @config_file_path = File.join(@path, CONF_DIR, CONFIG_FILE)
82
+ @conf_dir_path = File.join(@path, CONF_DIR)
83
+ @key_dir_path = File.join(@path, KEY_DIR)
84
+
85
+ @commit_author = { email: settings[:author_email], name: settings[:author_name] }
86
+
87
+ reload!
88
+ end
89
+
90
+ def config
91
+ @config ||= load_config
92
+ end
93
+
94
+
95
+ def config=(config)
96
+ @config = config
97
+ end
98
+
99
+
100
+ def ssh_keys
101
+ @ssh_keys ||= load_keys
102
+ end
103
+
104
+
105
+ def add_key(key)
106
+ unless key.instance_of? Gitolite::SSHKey
107
+ raise GitoliteAdminError, "Key must be of type Gitolite::SSHKey!"
108
+ end
109
+
110
+ ssh_keys[key.owner] << key
111
+ end
112
+
113
+
114
+ def rm_key(key)
115
+ unless key.instance_of? Gitolite::SSHKey
116
+ raise GitoliteAdminError, "Key must be of type Gitolite::SSHKey!"
117
+ end
118
+
119
+ ssh_keys[key.owner].delete key
120
+ end
121
+
122
+
123
+ # This method will destroy all local tracked changes, resetting the local gitolite
124
+ # git repo to HEAD and reloading the entire repository
125
+ def reset!
126
+ @repo.reset('origin/master', :hard)
127
+ reload!
128
+ end
129
+
130
+
131
+ # This method will destroy the in-memory data structures and reload everything
132
+ # from the file system
133
+ def reload!
134
+ @ssh_keys = load_keys
135
+ @config = load_config
136
+ end
137
+
138
+
139
+ # Writes all changed aspects out to the file system
140
+ # will also stage all changes then commit
141
+ def save()
142
+
143
+ # Add all changes to index (staging area)
144
+ index = @repo.index
145
+
146
+ #Process config file (if loaded, i.e. may be modified)
147
+ if @config
148
+ new_conf = @config.to_file(@conf_dir_path)
149
+
150
+ # Rugged wants relative paths
151
+ index.add(CONFIG_PATH)
152
+ end
153
+
154
+ #Process ssh keys (if loaded, i.e. may be modified)
155
+ if @ssh_keys
156
+ files = list_keys.map{|f| File.basename f}
157
+ keys = @ssh_keys.values.map{|f| f.map {|t| t.filename}}.flatten
158
+
159
+ to_remove = (files - keys).map { |f| File.join(@key_dir, f) }
160
+ to_remove.each do |key|
161
+ File.unlink key
162
+ index.remove key
163
+ end
164
+
165
+ @ssh_keys.each_value do |key|
166
+ # Write only keys from sets that has been modified
167
+ next if key.respond_to?(:dirty?) && !key.dirty?
168
+ key.each do |k|
169
+ new_key = k.to_file(@key_dir_path)
170
+ index.add new_key
171
+ end
172
+ end
173
+ end
174
+
175
+ # Write index to git and resync fs
176
+ commit_tree = index.write_tree @repo
177
+ index.write
178
+
179
+ commit_author = { email: 'wee@example.org', name: 'gitolite-rugged gem', time: Time.now }
180
+
181
+ Rugged::Commit.create(@repo,
182
+ author: commit_author,
183
+ committer: commit_author,
184
+ message: @settings[:commit_msg],
185
+ parents: [repo.head.target],
186
+ tree: commit_tree,
187
+ update_ref: 'HEAD'
188
+ )
189
+ end
190
+
191
+
192
+ # Push back to origin
193
+ def apply
194
+ @repo.push 'origin', ['refs/heads/master']
195
+ end
196
+
197
+
198
+ # Commits all staged changes and pushes back to origin
199
+ def save_and_apply()
200
+ save
201
+ apply
202
+ end
203
+
204
+
205
+ # Updates the repo with changes from remote master
206
+ # Warning: This resets the repo before pulling in the changes.
207
+ def update(settings = {})
208
+ reset!
209
+
210
+ # Currently, this only supports merging origin/master into master.
211
+ master = repo.branches["master"].target
212
+ origin_master = repo.branches["origin/master"].target
213
+
214
+ # Create the merged index in memory
215
+ merge_index = repo.merge_commits(master, origin_master)
216
+
217
+ # Complete the merge by comitting it
218
+ merge_commit = Rugged::Commit.create(@repo,
219
+ parents: [ master, origin_master ],
220
+ tree: merge_index.write_tree(@repo),
221
+ message: '[gitolite-rugged] Merged `origin/master` into `master`',
222
+ author: @commit_author,
223
+ committer: @commit_author,
224
+ update_ref: 'master'
225
+ )
226
+
227
+ reload!
228
+ end
229
+
230
+
231
+ private
232
+
233
+
234
+ # Clone the gitolite-admin repo
235
+ # to the given path.
236
+ #
237
+ # The repo is cloned from the url
238
+ # +(:git_user)@(:hostname)/gitolite-admin.git+
239
+ #
240
+ # The hostname may use an optional :port to allow for custom SSH ports.
241
+ # E.g., +git@localhost:2222/gitolite-admin.git+
242
+ #
243
+ def clone()
244
+ Rugged::Repository.clone_at(admin_url(@settings), File.expand_path(@path), credentials: @creds)
245
+ end
246
+
247
+
248
+ def load_config
249
+ Config.new(@config_file_path)
250
+ end
251
+
252
+
253
+ def list_keys
254
+ Dir.glob(@key_dir_path + '/**/*.pub')
255
+ end
256
+
257
+
258
+ # Loads all .pub files in the gitolite-admin
259
+ # keydir directory
260
+ def load_keys
261
+ keys = Hash.new {|k,v| k[v] = DirtyProxy.new([])}
262
+
263
+ list_keys.each do |key|
264
+ new_key = SSHKey.from_file(key)
265
+ owner = new_key.owner
266
+
267
+ keys[owner] << new_key
268
+ end
269
+
270
+ # Mark key sets as unmodified (for dirty checking)
271
+ keys.values.each{|set| set.clean_up!}
272
+
273
+ keys
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,103 @@
1
+ module Gitolite
2
+
3
+ # Models an SSH key within gitolite
4
+ # provides support for multikeys
5
+ #
6
+ # Types of multi keys:
7
+ # bob.pub => username: bob
8
+ # bob@desktop.pub => username: bob, location: desktop
9
+ # bob@email.com.pub => username: bob@email.com
10
+ # bob@email.com@desktop.pub => username: bob@email.com, location: desktop
11
+
12
+ class SSHKey
13
+
14
+ attr_accessor :owner, :location, :type, :blob, :email
15
+
16
+ class << self
17
+
18
+ def from_file(key)
19
+ raise "#{key} does not exist!" unless File.exists?(key)
20
+
21
+ # TODO this is old-style locations, use folders instead.
22
+ # Get our owner and location
23
+ File.basename(key) =~ /^([\+\w\.-]+(?:@(?:[\w-]+\.)+\D{2,4})?)(?:@([\w-]+))?.pub$/i
24
+ owner = $1
25
+ location = $2 || ""
26
+
27
+ # Use string key constructor
28
+ self.from_string(File.read(key), owner, location)
29
+ end
30
+
31
+
32
+ # Construct a SSHKey from a string
33
+ def from_string(key_string, owner, location = "")
34
+ if owner.nil?
35
+ raise ArgumentError, "owner was nil, you must specify an owner"
36
+ end
37
+
38
+ # Get parts of the key
39
+ type, blob, email = key_string.split
40
+
41
+ # We need at least a type or blob
42
+ if type.nil? || blob.nil?
43
+ raise ArgumentError, "'#{key_string}' is not a valid SSH key string"
44
+ end
45
+
46
+ # If the key didn't have an email, just use the owner
47
+ if email.nil?
48
+ email = owner
49
+ end
50
+
51
+ self.new(type, blob, email, owner, location)
52
+ end
53
+
54
+ end
55
+
56
+
57
+ def initialize(type, blob, email, owner = nil, location = "")
58
+ @type = type
59
+ @blob = blob
60
+ @email = email
61
+
62
+ @owner = owner || email
63
+ @location = location
64
+ end
65
+
66
+
67
+ def to_s
68
+ [@type, @blob, @email].join(' ')
69
+ end
70
+
71
+
72
+ def to_file(path)
73
+ key_file = File.join(path, self.filename)
74
+ File.open(key_file, "w") do |f|
75
+ f.sync = true
76
+ f.write(self.to_s)
77
+ end
78
+ key_file
79
+ end
80
+
81
+
82
+ def filename
83
+ file = @owner
84
+ file += "@#{@location}" unless @location.empty?
85
+ file += ".pub"
86
+ end
87
+
88
+
89
+ def ==(key)
90
+ @type == key.type &&
91
+ @blob == key.blob &&
92
+ @email == key.email &&
93
+ @owner == key.owner &&
94
+ @location == key.location
95
+ end
96
+
97
+
98
+ def hash
99
+ [@owner, @location, @type, @blob, @email].hash
100
+ end
101
+
102
+ end
103
+ end
@@ -0,0 +1,3 @@
1
+ module Gitolite
2
+ VERSION = "1.2-devel"
3
+ end
data/lib/gitolite.rb ADDED
@@ -0,0 +1,10 @@
1
+ module Gitolite
2
+ require 'rugged'
3
+ require 'gratr'
4
+ require 'gitolite/gitolite_admin'
5
+ require 'gitolite/dirty_proxy'
6
+ require 'gitolite/ssh_key'
7
+ require 'gitolite/config'
8
+ require 'gitolite/config/repo'
9
+ require 'gitolite/config/group'
10
+ end