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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +19 -9
- data/README.md +55 -3
- data/lib/generators/rails/worktrees/templates/rails_worktrees.rb.tt +19 -15
- data/lib/rails/worktrees/command/git_operations.rb +65 -4
- data/lib/rails/worktrees/command/output.rb +90 -3
- data/lib/rails/worktrees/command/workspace_paths.rb +34 -0
- data/lib/rails/worktrees/command.rb +429 -18
- data/lib/rails/worktrees/initializer_updater.rb +148 -0
- data/lib/rails/worktrees/project_maintenance.rb +326 -0
- data/lib/rails/worktrees/version.rb +1 -1
- data/lib/rails/worktrees.rb +2 -0
- metadata +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|