rails-worktrees 0.3.0 → 0.5.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.
@@ -10,6 +10,10 @@ module Rails
10
10
  # Creates or attaches worktrees for the current repository.
11
11
  # rubocop:disable Metrics/ClassLength
12
12
  class Command
13
+ REMOVE_SUBCOMMANDS = %w[remove delete].freeze
14
+ DOCTOR_SUBCOMMAND = 'doctor'.freeze
15
+ UPDATE_SUBCOMMAND = 'update'.freeze
16
+
13
17
  include GitOperations
14
18
  include EnvironmentSupport
15
19
  include NamePicking
@@ -24,18 +28,14 @@ module Rails
24
28
  @env = env
25
29
  @cwd = cwd
26
30
  @configuration = configuration
27
- @dry_run = @argv.first == '--dry-run'
28
- @argv.shift if dry_run?
31
+ extract_flags!
29
32
  end
30
33
 
31
34
  def run
32
- return usage_error if dry_run? && @argv.first&.start_with?('-')
33
-
34
35
  meta_command_result = handle_meta_command
35
36
  return meta_command_result unless meta_command_result.nil?
36
- return usage_error if @argv.length > 1
37
37
 
38
- execute_worktree_command
38
+ execute_requested_command
39
39
  rescue Error => e
40
40
  @stderr.puts("Error: #{e.message}")
41
41
  1
@@ -45,6 +45,50 @@ module Rails
45
45
 
46
46
  def dry_run? = @dry_run
47
47
 
48
+ def force? = @force
49
+
50
+ def extract_flags!
51
+ @dry_run = extract_flag!('--dry-run')
52
+ @force = extract_flag!('--force')
53
+ end
54
+
55
+ def extract_flag!(flag)
56
+ extracted = false
57
+ @argv.reject! do |arg|
58
+ next false unless arg == flag
59
+
60
+ extracted = true
61
+ true
62
+ end
63
+ extracted
64
+ end
65
+
66
+ def remove_subcommand?
67
+ REMOVE_SUBCOMMANDS.include?(@argv.first)
68
+ end
69
+
70
+ def prune_subcommand?
71
+ @argv.first == 'prune'
72
+ end
73
+
74
+ def doctor_subcommand?
75
+ @argv.first == DOCTOR_SUBCOMMAND
76
+ end
77
+
78
+ def update_subcommand?
79
+ @argv.first == UPDATE_SUBCOMMAND
80
+ end
81
+
82
+ def execute_requested_command
83
+ return execute_doctor_command if doctor_subcommand?
84
+ return execute_update_command if update_subcommand?
85
+ return execute_remove_command if remove_subcommand?
86
+ return execute_prune_command if prune_subcommand?
87
+ return usage_error if @argv.length > 1 || force?
88
+
89
+ execute_worktree_command
90
+ end
91
+
48
92
  def execute_worktree_command
49
93
  require_git_repo
50
94
  announce_dry_run if dry_run?
@@ -61,16 +105,155 @@ module Rails
61
105
  finish(context)
62
106
  end
63
107
 
64
- def resolve_worktree_context(explicit_worktree_name: nil)
65
- repo_root = git_capture('rev-parse', '--show-toplevel').strip
66
- project_name = File.basename(repo_root)
67
- workspaces = resolve_workspaces(repo_root, project_name)
108
+ def execute_remove_command
109
+ require_git_repo
110
+ announce_dry_run if dry_run?
111
+ validate_remove_args!
112
+
113
+ context = resolve_worktree_context(explicit_worktree_name: @argv.fetch(1))
114
+ removal_status = removal_status_for(context)
115
+
116
+ ensure_removable!(context, **removal_status)
117
+ return complete_remove_dry_run(context, **removal_status) if dry_run?
118
+
119
+ perform_remove(context, **removal_status)
120
+ end
121
+
122
+ def validate_remove_args!
123
+ raise Error, "Usage: wt #{@argv.first} [--dry-run] [--force] <worktree-name>" unless @argv.length == 2
124
+ end
125
+
126
+ def removal_status_for(context)
127
+ {
128
+ worktree_exists: File.exist?(context[:target_dir]),
129
+ branch_exists: branch_exists_locally?(context[:branch_name])
130
+ }
131
+ end
132
+
133
+ def perform_remove(context, worktree_exists:, branch_exists:)
134
+ remove_target_path(context[:target_dir]) if worktree_exists
135
+ delete_local_branch(context[:branch_name], force: force?) if branch_exists
136
+
137
+ success("Removed '#{context[:worktree_name]}'")
138
+ print_context_summary(context, env_values: nil)
139
+ 0
140
+ end
141
+
142
+ def execute_prune_command
143
+ require_git_repo
144
+ announce_dry_run if dry_run?
145
+ validate_prune_args!
146
+
147
+ candidates = prune_candidates
148
+ return complete_prune_noop if candidates.empty?
149
+
150
+ prepare_prune(candidates)
151
+ return complete_prune_dry_run(candidates) if dry_run?
152
+
153
+ perform_prune(candidates)
154
+
155
+ success("Pruned #{candidates.length} worktree#{'s' unless candidates.length == 1}")
156
+ 0
157
+ end
158
+
159
+ # rubocop:disable Metrics/MethodLength
160
+ def execute_doctor_command
161
+ validate_doctor_args!
162
+
163
+ unless git_success?('rev-parse', '--is-inside-work-tree')
164
+ checks = [doctor_check(category: :git, status: :warning,
165
+ headline: 'Run wt doctor from inside a Git repository.')]
166
+ print_doctor_report(checks)
167
+ return 1
168
+ end
169
+
170
+ repository = resolve_repository_context
171
+ checks = project_maintenance_report(repository[:current_root]).checks + doctor_worktree_checks(repository)
172
+ print_doctor_report(checks)
173
+
174
+ checks.any? { |check| check.fixable? || check.warning? } ? 1 : 0
175
+ end
176
+ # rubocop:enable Metrics/MethodLength
177
+
178
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
179
+ def execute_update_command
180
+ require_git_repo
181
+ validate_update_args!
182
+ announce_dry_run if dry_run?
183
+
184
+ current_root = resolve_repository_context[:current_root]
185
+ report = project_maintenance_report(current_root)
186
+ updated_count = 0
187
+ identical_count = 0
188
+ skipped_count = 0
189
+
190
+ report.checks.each do |check|
191
+ if check.fixable?
192
+ apply_maintenance_check(check, current_root: current_root)
193
+ updated_count += 1
194
+ elsif check.ok?
195
+ identical_count += 1
196
+ info(check.headline)
197
+ else
198
+ skipped_count += 1
199
+ warning(check.headline)
200
+ check.messages.each { |message| info(message) }
201
+ end
202
+ end
203
+
204
+ complete_update(updated_count:, identical_count:, skipped_count:)
205
+ end
206
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
207
+
208
+ def validate_prune_args!
209
+ raise Error, 'Usage: wt prune' unless @argv.length == 1
210
+ raise Error, 'The --force flag is only supported with wt remove.' if force?
211
+ end
212
+
213
+ def validate_doctor_args!
214
+ raise Error, 'Usage: wt doctor' unless @argv.length == 1
215
+ raise Error, 'wt doctor does not support --dry-run.' if dry_run?
216
+ raise Error, 'The --force flag is only supported with wt remove.' if force?
217
+ end
218
+
219
+ def validate_update_args!
220
+ raise Error, 'Usage: wt update [--dry-run]' unless @argv.length == 1
221
+ raise Error, 'The --force flag is only supported with wt remove.' if force?
222
+ end
223
+
224
+ def announce_prune_candidates(candidates)
225
+ info("Found #{candidates.length} merged worktree#{'s' unless candidates.length == 1} created by wt:")
226
+ end
227
+
228
+ def prepare_prune(candidates)
229
+ announce_prune_candidates(candidates)
230
+ print_prune_candidates(candidates)
231
+ confirm_or_abort!(prune_confirmation_prompt(candidates.length))
232
+ end
233
+
234
+ def prune_confirmation_prompt(count)
235
+ branches = count == 1 ? 'its local branch' : 'their local branches'
236
+ "Delete #{count} merged worktree#{'s' unless count == 1} and #{branches}?"
237
+ end
238
+
239
+ def perform_prune(candidates)
240
+ candidates.each do |context|
241
+ remove_target_path(context[:target_dir])
242
+ delete_local_branch(context[:branch_name], force: false)
243
+ end
244
+ end
245
+
246
+ def resolve_worktree_context(explicit_worktree_name: nil, repository: nil)
247
+ repository ||= resolve_repository_context
248
+ project_name = repository[:project_name]
249
+ workspaces = repository[:workspaces]
68
250
  worktree_name = resolved_worktree_name(project_name, workspaces, explicit_worktree_name)
69
251
 
70
- { project_name: project_name, workspaces_root: workspaces[:root], worktree_name: worktree_name,
71
- uses_default_workspace_root: workspaces[:uses_default_root],
252
+ repository.merge(
253
+ worktree_name: worktree_name,
72
254
  branch_name: branch_name_for(worktree_name),
73
- target_dir: target_dir_for(project_name, worktree_name, workspaces) }
255
+ target_dir: target_dir_for(project_name, worktree_name, workspaces)
256
+ )
74
257
  end
75
258
 
76
259
  def resolved_worktree_name(project_name, workspaces, explicit_worktree_name)
@@ -133,16 +316,244 @@ module Rails
133
316
  confirm_or_abort!("Target path '#{target_dir}' already exists. Remove it and recreate the worktree?")
134
317
  return info("Would remove existing target path '#{target_dir}'") if dry_run?
135
318
 
136
- if registered_worktree_path?(target_dir)
137
- remove_registered_worktree(target_dir)
138
- else
139
- FileUtils.rm_rf(target_dir)
140
- end
319
+ remove_target_path(target_dir)
141
320
  end
142
321
 
143
322
  def branch_name_for(worktree_name)
144
323
  "#{@configuration.branch_prefix}/#{worktree_name}"
145
324
  end
325
+
326
+ def ensure_removable!(context, worktree_exists:, branch_exists:)
327
+ ensure_remove_target_exists!(context, worktree_exists: worktree_exists, branch_exists: branch_exists)
328
+ ensure_not_removing_protected_checkout!(context)
329
+ ensure_branch_not_checked_out_here!(context)
330
+ ensure_branch_not_checked_out_elsewhere!(context)
331
+ ensure_local_branch_removable!(context[:branch_name], force: force?) if branch_exists
332
+ end
333
+
334
+ def ensure_remove_target_exists!(context, worktree_exists:, branch_exists:)
335
+ return if worktree_exists || branch_exists
336
+
337
+ raise Error, "No worktree or local branch found for '#{context[:worktree_name]}'."
338
+ end
339
+
340
+ def ensure_not_removing_protected_checkout!(context)
341
+ target_path = canonical_path(context[:target_dir])
342
+ raise Error, 'Cannot remove the main checkout.' if target_path == canonical_path(context[:primary_root])
343
+
344
+ return unless target_path == canonical_path(context[:current_root])
345
+
346
+ raise Error,
347
+ 'Cannot remove the current worktree from inside itself. ' \
348
+ 'Run this command from the main checkout or another worktree.'
349
+ end
350
+
351
+ def ensure_branch_not_checked_out_here!(context)
352
+ return unless current_checkout_branch == context[:branch_name]
353
+
354
+ raise Error,
355
+ "Branch '#{context[:branch_name]}' is checked out in the current worktree. " \
356
+ 'Run this command from the main checkout or another worktree.'
357
+ end
358
+
359
+ def ensure_branch_not_checked_out_elsewhere!(context)
360
+ target_path = canonical_path(context[:target_dir])
361
+ unexpected_paths = worktree_entries_for_branch(context[:branch_name])
362
+ .map { |entry| entry[:path] }
363
+ .reject { |path| path == target_path }
364
+ return if unexpected_paths.empty?
365
+
366
+ raise Error,
367
+ "Branch '#{context[:branch_name]}' is checked out in another worktree at '#{unexpected_paths.first}'."
368
+ end
369
+
370
+ def prune_candidates
371
+ repository = resolve_repository_context
372
+
373
+ worktree_entries.filter_map do |entry|
374
+ prune_candidate_for(entry, repository)
375
+ end
376
+ end
377
+
378
+ def prune_candidate_for(entry, repository)
379
+ branch_name = entry[:branch_name]
380
+ return unless prunable_worktree_entry?(entry, branch_name, repository)
381
+
382
+ context = resolve_worktree_context(
383
+ explicit_worktree_name: worktree_name_for_branch(branch_name),
384
+ repository: repository
385
+ )
386
+ return unless prune_target_matches_entry?(entry, context)
387
+
388
+ context
389
+ end
390
+
391
+ def prunable_worktree_entry?(entry, branch_name, repository)
392
+ wt_managed_branch?(branch_name) &&
393
+ !protected_prune_path?(entry[:path], repository) &&
394
+ !branch_checked_out_elsewhere_for_prune?(branch_name, entry[:path]) &&
395
+ branch_exists_locally?(branch_name) &&
396
+ branch_merged_into_default?(branch_name)
397
+ end
398
+
399
+ def branch_checked_out_elsewhere_for_prune?(branch_name, target_path)
400
+ worktree_entries_for_branch(branch_name).any? { |other| other[:path] != target_path }
401
+ end
402
+
403
+ def prune_target_matches_entry?(entry, context)
404
+ entry[:path] == canonical_path(context[:target_dir])
405
+ end
406
+
407
+ def protected_prune_path?(path, repository)
408
+ path == repository[:primary_root] || path == repository[:current_root]
409
+ end
410
+
411
+ def wt_managed_branch?(branch_name)
412
+ branch_name&.start_with?("#{@configuration.branch_prefix}/")
413
+ end
414
+
415
+ def worktree_name_for_branch(branch_name)
416
+ branch_name.delete_prefix("#{@configuration.branch_prefix}/")
417
+ end
418
+
419
+ def project_maintenance_report(root)
420
+ ::Rails::Worktrees::ProjectMaintenance.new(root: root).call
421
+ end
422
+
423
+ def doctor_worktree_checks(repository)
424
+ [
425
+ doctor_check(
426
+ category: :git,
427
+ status: :ok,
428
+ headline: "Git repository detected at #{repository[:current_root]}."
429
+ ),
430
+ default_branch_doctor_check,
431
+ stale_worktree_doctor_check
432
+ ]
433
+ end
434
+
435
+ def default_branch_doctor_check
436
+ doctor_check(
437
+ category: :git,
438
+ status: :ok,
439
+ headline: "origin default branch resolves to '#{resolve_default_branch}'."
440
+ )
441
+ rescue Error => e
442
+ doctor_check(category: :git, status: :warning, headline: e.message)
443
+ end
444
+
445
+ # rubocop:disable Metrics/MethodLength
446
+ def stale_worktree_doctor_check
447
+ stale_paths = worktree_entries.reject { |entry| File.directory?(entry[:path]) }
448
+ if stale_paths.empty?
449
+ doctor_check(category: :worktree, status: :ok, headline: 'No stale registered worktree paths found.')
450
+ else
451
+ doctor_check(
452
+ category: :worktree,
453
+ status: :warning,
454
+ headline: "Found #{stale_paths.length} stale registered worktree " \
455
+ "path#{'s' unless stale_paths.length == 1}.",
456
+ messages: stale_paths.map { |entry| entry[:path] }
457
+ )
458
+ end
459
+ end
460
+ # rubocop:enable Metrics/MethodLength
461
+
462
+ # rubocop:disable Metrics/MethodLength
463
+ def doctor_check(category:, status:, headline:, messages: [])
464
+ ::Rails::Worktrees::ProjectMaintenance::Check.new(
465
+ identifier: nil,
466
+ category: category,
467
+ status: status,
468
+ headline: headline,
469
+ messages: messages,
470
+ relative_path: nil,
471
+ updated_content: nil,
472
+ make_executable: false,
473
+ apply_messages: []
474
+ )
475
+ end
476
+ # rubocop:enable Metrics/MethodLength
477
+
478
+ # rubocop:disable Metrics/AbcSize
479
+ def apply_maintenance_check(check, current_root:)
480
+ if dry_run?
481
+ info("Would update #{check.relative_path}")
482
+ Array(check.apply_messages).each { |message| info(message) }
483
+ return
484
+ end
485
+
486
+ path = maintenance_destination_path(check.relative_path, current_root)
487
+ FileUtils.mkdir_p(File.dirname(path))
488
+ File.write(path, check.updated_content)
489
+ FileUtils.chmod(0o755, path) if check.make_executable
490
+
491
+ Array(check.apply_messages).each { |message| info(message) }
492
+ end
493
+
494
+ def maintenance_destination_path(relative_path, current_root)
495
+ root_path = File.realpath(current_root)
496
+ path = File.expand_path(relative_path, root_path)
497
+ assert_within_root!(path, root_path, "Refusing to write outside of repository root: #{path}")
498
+ parent_path = nearest_existing_parent(File.dirname(path))
499
+ assert_parent_within_root!(parent_path, root_path, path)
500
+ assert_not_symlink!(path)
501
+ path
502
+ end
503
+
504
+ def assert_within_root!(path, root_path, message)
505
+ raise Error, message unless within_root?(path, root_path)
506
+ end
507
+
508
+ def assert_parent_within_root!(parent_path, root_path, path)
509
+ assert_within_root!(
510
+ parent_path,
511
+ root_path,
512
+ "Refusing to write through symlinked directory outside repository root: #{path}"
513
+ )
514
+ end
515
+
516
+ def assert_not_symlink!(path)
517
+ raise Error, "Refusing to overwrite symlinked path: #{path}" if File.symlink?(path)
518
+ end
519
+
520
+ def within_root?(path, root_path)
521
+ path == root_path || path.start_with?("#{root_path}#{File::SEPARATOR}")
522
+ end
523
+
524
+ def nearest_existing_parent(path)
525
+ candidate = path
526
+ until File.exist?(candidate)
527
+ parent = File.dirname(candidate)
528
+ return candidate if parent == candidate
529
+
530
+ candidate = parent
531
+ end
532
+
533
+ File.realpath(candidate)
534
+ end
535
+ # rubocop:enable Metrics/AbcSize
536
+
537
+ # rubocop:disable Metrics/MethodLength
538
+ def complete_update(updated_count:, identical_count:, skipped_count:)
539
+ if dry_run?
540
+ success('Dry run complete')
541
+ info("Would update #{updated_count} file#{'s' unless updated_count == 1}.")
542
+ info('No changes were made.')
543
+ return 0
544
+ end
545
+
546
+ success('Update complete')
547
+ info(
548
+ [
549
+ "updated: #{updated_count}",
550
+ "already up to date: #{identical_count}",
551
+ "skipped: #{skipped_count}"
552
+ ].join(', ')
553
+ )
554
+ 0
555
+ end
556
+ # rubocop:enable Metrics/MethodLength
146
557
  end
147
558
  # rubocop:enable Metrics/ClassLength
148
559
  end
@@ -0,0 +1,148 @@
1
+ require 'erb'
2
+
3
+ module Rails
4
+ module Worktrees
5
+ # Safely updates the generated initializer to use the current gem-loading guard.
6
+ # rubocop:disable Metrics/ClassLength
7
+ class InitializerUpdater
8
+ Result = Struct.new(:content, :changed, :status, :messages) do
9
+ def changed?
10
+ changed
11
+ end
12
+ end
13
+
14
+ TEMPLATE_PATH = File.expand_path('../../generators/rails/worktrees/templates/rails_worktrees.rb.tt', __dir__)
15
+ CURRENT_GUARD_LINES = [
16
+ "if Gem.loaded_specs.key?('rails-worktrees') &&",
17
+ ' defined?(Rails::Worktrees) &&',
18
+ ' Rails::Worktrees.respond_to?(:configure)'
19
+ ].freeze
20
+ KNOWN_GUARD_FRAGMENTS = [
21
+ "Gem.loaded_specs.key?('rails-worktrees')",
22
+ 'defined?(Rails::Worktrees)',
23
+ 'Rails::Worktrees.respond_to?(:configure)'
24
+ ].freeze
25
+ CONFIGURE_CALL = 'Rails::Worktrees.configure do |config|'.freeze
26
+ LEGACY_GUARD = /\Aif defined\?\(Rails::Worktrees\)\n(?<body>.*)\nend\z/m
27
+
28
+ def self.default_content = new(content: '').send(:render_default_template)
29
+
30
+ def initialize(content:) = @content = content
31
+
32
+ def call
33
+ return identical_result if current_guard_present?
34
+ return updated_result(self.class.default_content) if blank_content?
35
+
36
+ body = wrapped_body
37
+ return skip_result unless body
38
+
39
+ updated_result(rebuild_content(body))
40
+ end
41
+
42
+ private
43
+
44
+ def identical_result
45
+ Result.new(
46
+ @content,
47
+ false,
48
+ :identical,
49
+ ['config/initializers/rails_worktrees.rb already uses the current safety guard.']
50
+ )
51
+ end
52
+
53
+ def updated_result(content)
54
+ Result.new(
55
+ content,
56
+ content != @content,
57
+ :updated,
58
+ ['Updated config/initializers/rails_worktrees.rb to use the current safety guard.']
59
+ )
60
+ end
61
+
62
+ def skip_result
63
+ Result.new(
64
+ @content,
65
+ false,
66
+ :skip,
67
+ ['config/initializers/rails_worktrees.rb is too custom to update automatically; review it manually.']
68
+ )
69
+ end
70
+
71
+ def blank_content? = @content.to_s.strip.empty?
72
+
73
+ def current_guard_present?
74
+ !extract_known_guard_body(@content.to_s.strip.lines, required_guard_lines: CURRENT_GUARD_LINES).nil?
75
+ end
76
+
77
+ def wrapped_body = extract_existing_body&.then { |body| normalize_body(body) }
78
+
79
+ def extract_existing_body
80
+ stripped = @content.to_s.strip
81
+ return if stripped.empty?
82
+
83
+ return Regexp.last_match[:body] if stripped.match(LEGACY_GUARD)
84
+
85
+ body = extract_known_guard_body(stripped.lines)
86
+ return body if body
87
+
88
+ body = extract_plain_configure_body(stripped.lines)
89
+ return body if body
90
+
91
+ nil
92
+ end
93
+
94
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
95
+ def extract_known_guard_body(lines, required_guard_lines: nil)
96
+ return if lines.empty? || lines.last.strip != 'end'
97
+
98
+ configure_index = lines.index { |line| line.strip == CONFIGURE_CALL }
99
+ return unless configure_index
100
+
101
+ guard_lines = lines[0...configure_index].reject { |line| line.strip.empty? }.map(&:rstrip)
102
+ return unless guard_lines.all? { |line| known_guard_line?(line) }
103
+ return if required_guard_lines && guard_lines != required_guard_lines
104
+
105
+ lines[configure_index...-1].join.rstrip
106
+ end
107
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
108
+
109
+ def extract_plain_configure_body(lines)
110
+ return unless lines.first&.strip == CONFIGURE_CALL && lines.last&.strip == 'end'
111
+
112
+ lines.join.rstrip
113
+ end
114
+
115
+ def known_guard_line?(line)
116
+ stripped = line.strip
117
+ return true if stripped.empty?
118
+
119
+ normalized = stripped.delete_suffix('&&').strip.sub(/\Aif\s+/, '')
120
+ KNOWN_GUARD_FRAGMENTS.include?(normalized)
121
+ end
122
+
123
+ def normalize_body(body)
124
+ body_lines = body.rstrip.lines
125
+ return '' if body_lines.empty?
126
+
127
+ return body.rstrip if body_lines.first.start_with?(' ')
128
+
129
+ body_lines.map { |line| line.strip.empty? ? line : " #{line}" }.join.rstrip
130
+ end
131
+
132
+ def rebuild_content(body)
133
+ content = [CURRENT_GUARD_LINES.join("\n"), body, 'end'].join("\n")
134
+ @content.end_with?("\n") || @content.empty? ? "#{content}\n" : content
135
+ end
136
+
137
+ def render_default_template
138
+ ERB.new(File.read(TEMPLATE_PATH), trim_mode: '-').result(template_context.instance_eval { binding })
139
+ end
140
+
141
+ def template_context
142
+ Struct.new(:options, :conductor_workspace_root).new({ 'conductor' => false },
143
+ "File.expand_path('~/Sites/conductor/workspaces')")
144
+ end
145
+ end
146
+ # rubocop:enable Metrics/ClassLength
147
+ end
148
+ end