rails-worktrees 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46dd1369d93b3281d8182a90812164a1a245ff778b5af05306908989563e264e
4
- data.tar.gz: 0d7e33ae49a56ea819e3a07ebfde966e1b1c18677af8c406bd15e93628ba8aff
3
+ metadata.gz: 9f11c54fe45c6061774b7181c80c7a25ba1c824d04fe06668ca0d6fe0bc3c453
4
+ data.tar.gz: 9470d8a967f77f0188679e8f9b50fd5a78c4aebe6f10e268cba00ffaff1ebea0
5
5
  SHA512:
6
- metadata.gz: 3d9e25b8d52ec84eb983d5b5ba28679394396232f61b8e9b626cf8a8f464b49e53d9f1898d0591bcd78e66620fa2b59869b1c781ed03b7a8a5f176f973a2ebbb
7
- data.tar.gz: 795403173d6b6c8c375119b7427bebc620cc73deb9402a8c1c6aa25c863b703af882b4e4b01dd9bdbd123696f1949a1e00f830a92a227e93ddff3e09a1c763ee
6
+ metadata.gz: c3775b72de293d0cf888f9b94cbbeae5b6031f001c641a1365f8f2662caa38de8b04bd4235a4e5c3f7b81279f6a23ce4637f4b2cfa6d6e1ba9d0fd0619daede2
7
+ data.tar.gz: 0c07249ae8bad61d6ae0cdad68f58752579145175e0d43005772e0d91f2f04b8d672f99a1952ce168e881d04e0b88ab4bbb8603183d4a9a2ace12f5c1767584e
@@ -1 +1 @@
1
- {".":"0.4.0"}
1
+ {".":"0.5.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/asjer/rails-worktrees/compare/v0.4.0...v0.5.0) (2026-04-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **maintenance:** add doctor and update maintenance commands ([fce44f7](https://github.com/asjer/rails-worktrees/commit/fce44f757ce9835eb663010f30a192c23f82080c))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **install:** support development-only gem installs ([bf9e742](https://github.com/asjer/rails-worktrees/commit/bf9e74260a88ba37674a5318b48ad5c93f1c0399))
14
+
3
15
  ## [0.4.0](https://github.com/asjer/rails-worktrees/compare/v0.3.0...v0.4.0) (2026-03-30)
4
16
 
5
17
 
@@ -52,12 +64,3 @@
52
64
  ### Features
53
65
 
54
66
  * add wt command and rails installer ([5d798d5](https://github.com/asjer/rails-worktrees/commit/5d798d5129585331780f0259b39061194feb66e3))
55
-
56
- ## [Unreleased]
57
-
58
- - Add a gem-managed `wt` CLI for creating Rails worktrees.
59
- - Add an optional gem-managed `ob` CLI plus generated `bin/ob` wrapper for opening `localhost:$DEV_PORT` routes.
60
- - Add a Rails installer generator that creates `bin/wt` and `config/initializers/rails_worktrees.rb`.
61
- - Add conservative `config/database.yml` patching for common development/test database names.
62
- - Add a manual-dispatch GitHub Actions workflow for the disposable Rails smoke test.
63
- - Add smoke-test workflow debug controls for retained artifacts and verbose output.
data/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  ## Installation
12
12
 
13
13
  ```bash
14
- bundle add rails-worktrees
14
+ bundle add rails-worktrees --group development
15
15
  bin/rails generate worktrees:install
16
16
  # or, to also generate bin/ob without the yolo follow-ups:
17
17
  bin/rails generate worktrees:install --browser
@@ -19,6 +19,8 @@ bin/rails generate worktrees:install --browser
19
19
  bin/rails generate worktrees:install --yolo
20
20
  ```
21
21
 
22
+ The generated initializer checks that `rails-worktrees` is actually loaded before it calls `Rails::Worktrees.configure`, so the app can still boot in environments like `test` when the gem is only bundled for `:development`.
23
+
22
24
  The installer adds:
23
25
 
24
26
  - `bin/wt` — a thin wrapper that executes the gem-owned CLI
@@ -42,6 +44,9 @@ bin/wt # auto-pick a name from bundled *.txt lists
42
44
  bin/wt my-feature # use an explicit worktree name
43
45
  bin/wt --dry-run my-feature # preview the full setup without changing anything
44
46
  bin/wt --print-env my-feature # preview DEV_PORT and WORKTREE_DATABASE_SUFFIX
47
+ bin/wt doctor # audit install/config drift and basic worktree health
48
+ bin/wt update --dry-run # preview safe maintenance fixes
49
+ bin/wt update # apply safe maintenance fixes for managed files
45
50
  bin/wt remove my-feature # remove a worktree and delete its local branch
46
51
  bin/wt delete my-feature # alias for `bin/wt remove`
47
52
  bin/wt remove --force my-feature # also delete an unmerged local branch
@@ -111,6 +116,18 @@ If you want to see what `bin/wt prune` would clean up before saying yes, use `--
111
116
  bin/wt prune --dry-run
112
117
  ```
113
118
 
119
+ ### Maintenance commands
120
+
121
+ `bin/wt` also includes a small maintenance surface for installer drift and checkout health:
122
+
123
+ - `bin/wt doctor` — audit managed files plus basic Git/worktree health without changing anything
124
+ - `bin/wt update --dry-run` — preview safe file-based fixes
125
+ - `bin/wt update` — apply safe file-based fixes for supported managed files
126
+
127
+ `bin/wt doctor` checks the generated initializer, `bin/wt`, optional `bin/ob`, supported config files such as `config/database.yml`, `Procfile.dev`, `config/puma.rb`, and `mise.toml`/`.mise.toml`, plus basic repository/worktree conditions like resolving `origin`'s default branch and spotting stale registered worktree paths.
128
+
129
+ `bin/wt update` is intentionally conservative: it only applies safe file-based fixes for managed templates and supported config shapes. It does **not** delete branches, remove worktrees, or rewrite ambiguous custom files automatically.
130
+
114
131
 
115
132
  ### Interactive prompts
116
133
 
@@ -131,6 +148,8 @@ Worktree names must not contain `/` or whitespace, must not be `.` or `..`, and
131
148
 
132
149
  The installer generates `config/initializers/rails_worktrees.rb` where you can override:
133
150
 
151
+ The initializer becomes a no-op whenever `rails-worktrees` is not loaded in the current bundle groups.
152
+
134
153
  | Option | Default | Description |
135
154
  |--------|---------|-------------|
136
155
  | `bootstrap_env` | `true` | Write `.env` when creating a worktree |
@@ -1,19 +1,23 @@
1
- Rails::Worktrees.configure do |config|
1
+ if Gem.loaded_specs.key?('rails-worktrees') &&
2
+ defined?(Rails::Worktrees) &&
3
+ Rails::Worktrees.respond_to?(:configure)
4
+ Rails::Worktrees.configure do |config|
2
5
  <% if options['conductor'] -%>
3
- config.workspace_root = <%= conductor_workspace_root %>
6
+ config.workspace_root = <%= conductor_workspace_root %>
4
7
  <% else -%>
5
- # By default, worktrees go in a sibling "<project>.worktrees" directory.
6
- # Uncomment to override with a custom parent directory that uses <root>/<project>/<name>.
7
- # config.workspace_root = File.expand_path('~/worktrees')
8
+ # By default, worktrees go in a sibling "<project>.worktrees" directory.
9
+ # Uncomment to override with a custom parent directory that uses <root>/<project>/<name>.
10
+ # config.workspace_root = File.expand_path('~/worktrees')
8
11
  <% end -%>
9
- # config.bootstrap_env = false
10
- # config.dev_port_range = 3000..3999
11
- # config.worktree_database_suffix_max_length = 18
12
- # config.branch_prefix = '🚂'
13
- # config.name_sources_path = Rails.root.join('config/worktree_names').to_s
14
- # config.used_names_file = File.join(
15
- # ENV.fetch('XDG_STATE_HOME', File.expand_path('~/.local/state')),
16
- # 'rails-worktrees',
17
- # 'used-names.tsv'
18
- # )
12
+ # config.bootstrap_env = false
13
+ # config.dev_port_range = 3000..3999
14
+ # config.worktree_database_suffix_max_length = 18
15
+ # config.branch_prefix = '🚂'
16
+ # config.name_sources_path = Rails.root.join('config/worktree_names').to_s
17
+ # config.used_names_file = File.join(
18
+ # ENV.fetch('XDG_STATE_HOME', File.expand_path('~/.local/state')),
19
+ # 'rails-worktrees',
20
+ # 'used-names.tsv'
21
+ # )
22
+ end
19
23
  end
@@ -32,6 +32,8 @@ module Rails
32
32
  Usage: wt [worktree-name]
33
33
  wt --dry-run [worktree-name]
34
34
  wt --print-env <worktree-name>
35
+ wt doctor
36
+ wt update [--dry-run]
35
37
  wt remove [--dry-run] [--force] <worktree-name>
36
38
  wt delete [--dry-run] [--force] <worktree-name>
37
39
  wt prune [--dry-run]
@@ -48,6 +50,8 @@ module Rails
48
50
  wt my-feature Use an explicit worktree name
49
51
  wt --dry-run my-feature
50
52
  wt --print-env my-feature
53
+ wt doctor
54
+ wt update --dry-run
51
55
  wt remove my-feature
52
56
  wt remove --force my-feature
53
57
  wt prune
@@ -57,6 +61,8 @@ module Rails
57
61
  - when workspace_root or WT_WORKSPACES_ROOT is set, creates worktrees in <root>/<project>/<name>
58
62
  - always uses the branch name #{@configuration.branch_prefix}/<name>
59
63
  - bases new branches on the repository's origin default branch
64
+ - wt doctor audits install/config drift plus basic worktree health without changing files
65
+ - wt update applies safe file-based fixes for managed installer artifacts and config hints
60
66
  - wt remove/delete can run from the main checkout or any sibling worktree, but never remove the worktree you're currently in
61
67
  - wt prune removes merged worktrees created by wt while skipping the main checkout and the checkout you're in
62
68
  - auto-discovers bundled *.txt files from #{@configuration.name_sources_path}
@@ -147,6 +153,29 @@ module Rails
147
153
  0
148
154
  end
149
155
 
156
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
157
+ def print_doctor_report(checks)
158
+ checks.each do |check|
159
+ printer = check.ok? ? :info : :warning
160
+ send(printer, "#{check.category}: #{check.headline}")
161
+ Array(check.messages).each { |message| info(message) }
162
+ end
163
+
164
+ fixable_count = checks.count(&:fixable?)
165
+ warning_count = checks.count(&:warning?)
166
+
167
+ if fixable_count.zero? && warning_count.zero?
168
+ success('Doctor found no issues.')
169
+ else
170
+ warning(
171
+ "Doctor found #{fixable_count} fixable issue#{'s' unless fixable_count == 1} and " \
172
+ "#{warning_count} warning#{'s' unless warning_count == 1}."
173
+ )
174
+ info('Run `wt update --dry-run` to preview safe fixes.') if fixable_count.positive?
175
+ end
176
+ end
177
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
178
+
150
179
  def warning(message)
151
180
  @stderr.puts("⚠️ #{message}")
152
181
  end
@@ -11,6 +11,8 @@ module Rails
11
11
  # rubocop:disable Metrics/ClassLength
12
12
  class Command
13
13
  REMOVE_SUBCOMMANDS = %w[remove delete].freeze
14
+ DOCTOR_SUBCOMMAND = 'doctor'.freeze
15
+ UPDATE_SUBCOMMAND = 'update'.freeze
14
16
 
15
17
  include GitOperations
16
18
  include EnvironmentSupport
@@ -69,7 +71,17 @@ module Rails
69
71
  @argv.first == 'prune'
70
72
  end
71
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
+
72
82
  def execute_requested_command
83
+ return execute_doctor_command if doctor_subcommand?
84
+ return execute_update_command if update_subcommand?
73
85
  return execute_remove_command if remove_subcommand?
74
86
  return execute_prune_command if prune_subcommand?
75
87
  return usage_error if @argv.length > 1 || force?
@@ -144,11 +156,71 @@ module Rails
144
156
  0
145
157
  end
146
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
+
147
208
  def validate_prune_args!
148
209
  raise Error, 'Usage: wt prune' unless @argv.length == 1
149
210
  raise Error, 'The --force flag is only supported with wt remove.' if force?
150
211
  end
151
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
+
152
224
  def announce_prune_candidates(candidates)
153
225
  info("Found #{candidates.length} merged worktree#{'s' unless candidates.length == 1} created by wt:")
154
226
  end
@@ -343,6 +415,145 @@ module Rails
343
415
  def worktree_name_for_branch(branch_name)
344
416
  branch_name.delete_prefix("#{@configuration.branch_prefix}/")
345
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
346
557
  end
347
558
  # rubocop:enable Metrics/ClassLength
348
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
@@ -0,0 +1,326 @@
1
+ module Rails
2
+ module Worktrees
3
+ # Audits and prepares safe file-based maintenance updates for the current checkout.
4
+ # rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
5
+ class ProjectMaintenance
6
+ # rubocop:disable Style/RedundantStructKeywordInit
7
+ Check = Struct.new(
8
+ :identifier,
9
+ :category,
10
+ :headline,
11
+ :messages,
12
+ :relative_path,
13
+ :status,
14
+ :updated_content,
15
+ :make_executable,
16
+ :apply_messages,
17
+ keyword_init: true
18
+ ) do
19
+ def ok?
20
+ status == :ok
21
+ end
22
+
23
+ def fixable?
24
+ status == :fixable
25
+ end
26
+
27
+ def warning?
28
+ status == :warning
29
+ end
30
+
31
+ def updatable?
32
+ !updated_content.nil?
33
+ end
34
+ end
35
+
36
+ Report = Struct.new(:checks, keyword_init: true) do
37
+ def fixable_checks
38
+ checks.select(&:fixable?)
39
+ end
40
+
41
+ def warning_checks
42
+ checks.select(&:warning?)
43
+ end
44
+
45
+ def ok?
46
+ fixable_checks.empty? && warning_checks.empty?
47
+ end
48
+ end
49
+ # rubocop:enable Style/RedundantStructKeywordInit
50
+
51
+ TEMPLATE_ROOT = File.expand_path('../../generators/rails/worktrees/templates', __dir__)
52
+
53
+ def initialize(root:)
54
+ @root = root
55
+ end
56
+
57
+ def call
58
+ Report.new(checks: maintenance_checks.compact)
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :root
64
+
65
+ def maintenance_checks
66
+ [
67
+ template_file_check(wrapper_config),
68
+ initializer_check,
69
+ database_check,
70
+ optional_updater_check(procfile_config) { |content| ProcfileUpdater.new(content: content).call },
71
+ optional_updater_check(puma_config) { |content| PumaConfigUpdater.new(content: content).call },
72
+ mise_check,
73
+ template_file_check(browser_wrapper_config)
74
+ ]
75
+ end
76
+
77
+ def wrapper_config
78
+ {
79
+ identifier: :bin_wt,
80
+ category: :install,
81
+ relative_path: 'bin/wt',
82
+ template_path: File.join(TEMPLATE_ROOT, 'bin/wt'),
83
+ make_executable: true,
84
+ optional: false
85
+ }
86
+ end
87
+
88
+ def browser_wrapper_config
89
+ {
90
+ identifier: :bin_ob,
91
+ category: :install,
92
+ relative_path: 'bin/ob',
93
+ template_path: File.join(TEMPLATE_ROOT, 'bin/ob'),
94
+ make_executable: true,
95
+ optional: true
96
+ }
97
+ end
98
+
99
+ def template_file_check(config)
100
+ path = absolute_path(config.fetch(:relative_path))
101
+ return nil if config.fetch(:optional) && !File.exist?(path)
102
+
103
+ desired_content = File.read(config.fetch(:template_path))
104
+ return template_missing_check(config, desired_content) unless File.exist?(path)
105
+
106
+ current_content = File.read(path)
107
+ if current_content == desired_content
108
+ if executable_mode_current?(config, path)
109
+ return ok_check(config, "#{config.fetch(:relative_path)} is up to date.")
110
+ end
111
+
112
+ return executable_permission_check(config, desired_content)
113
+ end
114
+
115
+ fixable_check(
116
+ config,
117
+ "#{config.fetch(:relative_path)} differs from the managed template.",
118
+ updated_content: desired_content,
119
+ apply_messages: ["Updated #{config.fetch(:relative_path)} to match the managed template."],
120
+ make_executable: config.fetch(:make_executable, false)
121
+ )
122
+ end
123
+
124
+ def executable_mode_current?(config, path)
125
+ !config.fetch(:make_executable, false) || File.executable?(path)
126
+ end
127
+
128
+ def executable_permission_check(config, desired_content)
129
+ relative_path = config.fetch(:relative_path)
130
+ fixable_check(
131
+ config,
132
+ "#{relative_path} needs its executable bit restored.",
133
+ updated_content: desired_content,
134
+ apply_messages: ["Restored executable permissions on #{relative_path}."],
135
+ make_executable: true
136
+ )
137
+ end
138
+
139
+ def template_missing_check(config, desired_content)
140
+ fixable_check(
141
+ config,
142
+ "#{config.fetch(:relative_path)} is missing.",
143
+ updated_content: desired_content,
144
+ apply_messages: ["Created #{config.fetch(:relative_path)} from the managed template."],
145
+ make_executable: config.fetch(:make_executable, false)
146
+ )
147
+ end
148
+
149
+ def initializer_check
150
+ config = {
151
+ identifier: :initializer,
152
+ category: :install,
153
+ relative_path: 'config/initializers/rails_worktrees.rb',
154
+ identical_headline: 'config/initializers/rails_worktrees.rb already uses the current safety guard.',
155
+ fixable_headline: 'config/initializers/rails_worktrees.rb can be updated automatically.',
156
+ warning_headline: 'config/initializers/rails_worktrees.rb needs manual review.'
157
+ }
158
+ path = absolute_path(config.fetch(:relative_path))
159
+
160
+ unless File.exist?(path)
161
+ return fixable_check(
162
+ config,
163
+ "#{config.fetch(:relative_path)} is missing.",
164
+ updated_content: InitializerUpdater.default_content,
165
+ apply_messages: ['Created config/initializers/rails_worktrees.rb with the current safety guard.']
166
+ )
167
+ end
168
+
169
+ updater_result_check(config, InitializerUpdater.new(content: File.read(path)).call)
170
+ end
171
+
172
+ def database_check
173
+ config = {
174
+ identifier: :database,
175
+ category: :config,
176
+ relative_path: 'config/database.yml'
177
+ }
178
+ path = absolute_path(config.fetch(:relative_path))
179
+
180
+ unless File.exist?(path)
181
+ return warning_check(
182
+ config,
183
+ 'config/database.yml is missing.',
184
+ ['Add WORKTREE_DATABASE_SUFFIX support manually if this app uses a custom database setup.']
185
+ )
186
+ end
187
+
188
+ result = DatabaseConfigUpdater.new(content: File.read(path)).call
189
+ return updated_database_check(config, result) if result.changed?
190
+
191
+ if database_configured?(result)
192
+ return ok_check(
193
+ config,
194
+ 'config/database.yml already includes WORKTREE_DATABASE_SUFFIX in supported entries.'
195
+ )
196
+ end
197
+
198
+ warning_check(
199
+ config,
200
+ result.messages.first || 'config/database.yml needs manual review.',
201
+ result.messages.drop(1)
202
+ )
203
+ end
204
+
205
+ def updated_database_check(config, result)
206
+ fixable_check(
207
+ config,
208
+ 'config/database.yml can be updated automatically.',
209
+ messages: result.messages.drop(1),
210
+ updated_content: result.content,
211
+ apply_messages: result.messages
212
+ )
213
+ end
214
+
215
+ def database_configured?(result)
216
+ result.messages.first == 'Development/test database names already include WORKTREE_DATABASE_SUFFIX.'
217
+ end
218
+
219
+ def procfile_config
220
+ {
221
+ identifier: :procfile,
222
+ category: :config,
223
+ relative_path: 'Procfile.dev',
224
+ identical_headline: 'Procfile.dev already uses the DEV_PORT-aware web entry.',
225
+ fixable_headline: 'Procfile.dev can be updated automatically.',
226
+ warning_headline: 'Procfile.dev needs manual review.'
227
+ }
228
+ end
229
+
230
+ def puma_config
231
+ {
232
+ identifier: :puma,
233
+ category: :config,
234
+ relative_path: 'config/puma.rb',
235
+ identical_headline: 'config/puma.rb already prefers DEV_PORT.',
236
+ fixable_headline: 'config/puma.rb can be updated automatically.',
237
+ warning_headline: 'config/puma.rb needs manual review.'
238
+ }
239
+ end
240
+
241
+ def optional_updater_check(config)
242
+ path = absolute_path(config.fetch(:relative_path))
243
+ return unless File.exist?(path)
244
+
245
+ updater_result_check(config, yield(File.read(path)))
246
+ end
247
+
248
+ def mise_check
249
+ relative_path = %w[mise.toml .mise.toml].find { |candidate| File.exist?(absolute_path(candidate)) }
250
+ return unless relative_path
251
+
252
+ config = {
253
+ identifier: :mise,
254
+ category: :config,
255
+ relative_path: relative_path,
256
+ identical_headline: "#{relative_path} already loads .env from [env].",
257
+ fixable_headline: "#{relative_path} can be updated automatically.",
258
+ warning_headline: "#{relative_path} needs manual review."
259
+ }
260
+
261
+ updater_result_check(config, mise_updater_result(relative_path))
262
+ end
263
+
264
+ def mise_updater_result(relative_path)
265
+ MiseTomlUpdater.new(
266
+ content: File.read(absolute_path(relative_path)),
267
+ file_name: File.basename(relative_path)
268
+ ).call
269
+ end
270
+
271
+ def updater_result_check(config, result)
272
+ case result.status
273
+ when :identical
274
+ ok_check(config, config.fetch(:identical_headline))
275
+ when :updated
276
+ fixable_check(
277
+ config,
278
+ config.fetch(:fixable_headline),
279
+ updated_content: result.content,
280
+ apply_messages: result.messages
281
+ )
282
+ else
283
+ warning_check(config, config.fetch(:warning_headline), result.messages)
284
+ end
285
+ end
286
+
287
+ def ok_check(config, headline)
288
+ build_check(config, status: :ok, headline: headline)
289
+ end
290
+
291
+ def warning_check(config, headline, messages = [])
292
+ build_check(config, status: :warning, headline: headline, messages: messages)
293
+ end
294
+
295
+ def fixable_check(config, headline, updated_content:, apply_messages:, **attributes)
296
+ build_check(
297
+ config,
298
+ status: :fixable,
299
+ headline: headline,
300
+ updated_content: updated_content,
301
+ apply_messages: apply_messages,
302
+ **attributes
303
+ )
304
+ end
305
+
306
+ def build_check(config, status:, headline:, **attributes)
307
+ Check.new(
308
+ identifier: config.fetch(:identifier),
309
+ category: config.fetch(:category),
310
+ relative_path: config.fetch(:relative_path),
311
+ status: status,
312
+ headline: headline,
313
+ messages: attributes.fetch(:messages, []),
314
+ updated_content: attributes.fetch(:updated_content, nil),
315
+ make_executable: attributes.fetch(:make_executable, false),
316
+ apply_messages: attributes.fetch(:apply_messages, [])
317
+ )
318
+ end
319
+
320
+ def absolute_path(relative_path)
321
+ File.join(root, relative_path)
322
+ end
323
+ end
324
+ # rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
325
+ end
326
+ end
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Worktrees
3
- VERSION = '0.4.0'.freeze
3
+ VERSION = '0.5.0'.freeze
4
4
  end
5
5
  end
@@ -7,9 +7,11 @@ require_relative 'worktrees/command'
7
7
  require_relative 'worktrees/cli'
8
8
  require_relative 'worktrees/browser_command'
9
9
  require_relative 'worktrees/database_config_updater'
10
+ require_relative 'worktrees/initializer_updater'
10
11
  require_relative 'worktrees/procfile_updater'
11
12
  require_relative 'worktrees/mise_toml_updater'
12
13
  require_relative 'worktrees/puma_config_updater'
14
+ require_relative 'worktrees/project_maintenance'
13
15
 
14
16
  module Rails
15
17
  # Rails-specific git worktree helpers and installer support.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-worktrees
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asjer Querido
@@ -66,9 +66,11 @@ files:
66
66
  - lib/rails/worktrees/configuration.rb
67
67
  - lib/rails/worktrees/database_config_updater.rb
68
68
  - lib/rails/worktrees/env_bootstrapper.rb
69
+ - lib/rails/worktrees/initializer_updater.rb
69
70
  - lib/rails/worktrees/mise_toml_updater.rb
70
71
  - lib/rails/worktrees/names/cities.txt
71
72
  - lib/rails/worktrees/procfile_updater.rb
73
+ - lib/rails/worktrees/project_maintenance.rb
72
74
  - lib/rails/worktrees/puma_config_updater.rb
73
75
  - lib/rails/worktrees/railtie.rb
74
76
  - lib/rails/worktrees/version.rb