rails-worktrees 0.4.0 → 0.5.1
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 +20 -1
- data/lib/generators/rails/worktrees/templates/rails_worktrees.rb.tt +2 -2
- data/lib/rails/worktrees/application_configuration.rb +36 -0
- data/lib/rails/worktrees/cli.rb +33 -7
- data/lib/rails/worktrees/command/output.rb +29 -0
- data/lib/rails/worktrees/command.rb +211 -0
- data/lib/rails/worktrees/configuration.rb +10 -0
- data/lib/rails/worktrees/initializer_updater.rb +171 -0
- data/lib/rails/worktrees/project_configuration_loader.rb +205 -0
- data/lib/rails/worktrees/project_maintenance.rb +328 -0
- data/lib/rails/worktrees/railtie.rb +4 -0
- data/lib/rails/worktrees/version.rb +1 -1
- data/lib/rails/worktrees.rb +8 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 698795e04a67284039d3beba2286f3a9aa156a5856832a345f22ffa57b6177e3
|
|
4
|
+
data.tar.gz: 0a57ef25c66c3226798498476abf8caf033c6a2989ae170b30a161fde66fddcf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 06c8ae396186a19c08e45445e031a28198d48f7ea5125c02e63b2df39977f5399f75421e7f1897071d14b00d042051616ed4e447b83b570852d54756d8689377
|
|
7
|
+
data.tar.gz: 800a19ef60af420e03280b8a30c02c9012c4ded0d3eaecc421ed5e42e072b8024a0cbc93e7ba7aaf7d806dc972deacacbb0c5edfe45da40b2ab05659b1ccc5c5
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{".":"0.
|
|
1
|
+
{".":"0.5.1"}
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.1](https://github.com/asjer/rails-worktrees/compare/v0.5.0...v0.5.1) (2026-04-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **initializer:** simplify generated config wiring ([bd7fd50](https://github.com/asjer/rails-worktrees/commit/bd7fd506f2c0106a0bd188e36b31329a9de2acbf))
|
|
9
|
+
|
|
10
|
+
## [0.5.0](https://github.com/asjer/rails-worktrees/compare/v0.4.0...v0.5.0) (2026-04-02)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **maintenance:** add doctor and update maintenance commands ([fce44f7](https://github.com/asjer/rails-worktrees/commit/fce44f757ce9835eb663010f30a192c23f82080c))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **install:** support development-only gem installs ([bf9e742](https://github.com/asjer/rails-worktrees/commit/bf9e74260a88ba37674a5318b48ad5c93f1c0399))
|
|
21
|
+
|
|
3
22
|
## [0.4.0](https://github.com/asjer/rails-worktrees/compare/v0.3.0...v0.4.0) (2026-03-30)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -52,12 +71,3 @@
|
|
|
52
71
|
### Features
|
|
53
72
|
|
|
54
73
|
* 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 writes app config under `Rails.application.config.x.rails_worktrees`, so the file stays safe to load even in environments like `test` where the gem is only bundled for `:development`. When `rails-worktrees` is loaded, the gem applies those settings for both the CLI and in-process Rails usage.
|
|
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 stays app-owned and gem-agnostic; `rails-worktrees` reads those settings whenever the gem is present 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,4 +1,4 @@
|
|
|
1
|
-
Rails
|
|
1
|
+
Rails.application.config.x.rails_worktrees.tap do |config|
|
|
2
2
|
<% if options['conductor'] -%>
|
|
3
3
|
config.workspace_root = <%= conductor_workspace_root %>
|
|
4
4
|
<% else -%>
|
|
@@ -16,4 +16,4 @@ Rails::Worktrees.configure do |config|
|
|
|
16
16
|
# 'rails-worktrees',
|
|
17
17
|
# 'used-names.tsv'
|
|
18
18
|
# )
|
|
19
|
-
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Worktrees
|
|
3
|
+
# Copies explicit app configuration values onto the runtime configuration object.
|
|
4
|
+
module ApplicationConfiguration
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def apply(source, configuration:)
|
|
8
|
+
return configuration unless source
|
|
9
|
+
|
|
10
|
+
Configuration::CONFIGURABLE_ATTRIBUTES.each do |attribute|
|
|
11
|
+
next unless assigned?(source, attribute)
|
|
12
|
+
|
|
13
|
+
configuration.public_send("#{attribute}=", value_for(source, attribute))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
configuration
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def assigned?(source, attribute)
|
|
20
|
+
key = attribute.to_sym
|
|
21
|
+
hash = source.is_a?(Hash) ? source : source.to_h if source.respond_to?(:to_h)
|
|
22
|
+
return hash.key?(key) || hash.key?(attribute.to_s) if hash
|
|
23
|
+
|
|
24
|
+
source.respond_to?(:key?) && (source.key?(key) || source.key?(attribute.to_s))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def value_for(source, attribute)
|
|
28
|
+
key = attribute.to_sym
|
|
29
|
+
hash = source.is_a?(Hash) ? source : source.to_h if source.respond_to?(:to_h)
|
|
30
|
+
return hash.fetch(key) { hash[attribute.to_s] } if hash
|
|
31
|
+
|
|
32
|
+
source.public_send(attribute)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/rails/worktrees/cli.rb
CHANGED
|
@@ -2,6 +2,9 @@ module Rails
|
|
|
2
2
|
module Worktrees
|
|
3
3
|
# Shell entrypoint for the wt executable.
|
|
4
4
|
class CLI
|
|
5
|
+
LOADER_OPTIONAL_COMMANDS = %w[doctor update -h --help -v --version].freeze
|
|
6
|
+
LOADER_IGNORED_FLAGS = %w[--dry-run --force].freeze
|
|
7
|
+
|
|
5
8
|
def initialize(
|
|
6
9
|
argv: ARGV,
|
|
7
10
|
io: { stdin: $stdin, stdout: $stdout, stderr: $stderr },
|
|
@@ -15,13 +18,36 @@ module Rails
|
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
def start
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
configuration = ::Rails::Worktrees.configuration
|
|
22
|
+
load_project_configuration(configuration) if should_load_project_configuration?
|
|
23
|
+
command_for(configuration).run
|
|
24
|
+
rescue ::Rails::Worktrees::Error => e
|
|
25
|
+
@io.fetch(:stderr).puts("Error: #{e.message}")
|
|
26
|
+
1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def load_project_configuration(configuration)
|
|
32
|
+
::Rails::Worktrees::ProjectConfigurationLoader.new(root: @cwd, configuration: configuration).call
|
|
33
|
+
rescue StandardError, ScriptError => e
|
|
34
|
+
raise ::Rails::Worktrees::Error, "Failed to load worktrees configuration: #{e.class}: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def should_load_project_configuration?
|
|
38
|
+
argv_without_flags.empty? || !loader_optional_command?(argv_without_flags.first)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def argv_without_flags
|
|
42
|
+
@argv.reject { |arg| LOADER_IGNORED_FLAGS.include?(arg) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def loader_optional_command?(command)
|
|
46
|
+
LOADER_OPTIONAL_COMMANDS.include?(command)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def command_for(configuration)
|
|
50
|
+
Command.new(argv: @argv, io: @io, env: @env, cwd: @cwd, configuration: configuration)
|
|
25
51
|
end
|
|
26
52
|
end
|
|
27
53
|
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
|
|
@@ -2,6 +2,16 @@ module Rails
|
|
|
2
2
|
module Worktrees
|
|
3
3
|
# Stores application-level settings for the wt command.
|
|
4
4
|
class Configuration
|
|
5
|
+
CONFIGURABLE_ATTRIBUTES = %i[
|
|
6
|
+
bootstrap_env
|
|
7
|
+
workspace_root
|
|
8
|
+
dev_port_range
|
|
9
|
+
branch_prefix
|
|
10
|
+
name_sources_path
|
|
11
|
+
used_names_file
|
|
12
|
+
worktree_database_suffix_max_length
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
5
15
|
DEFAULT_BOOTSTRAP_ENV = true
|
|
6
16
|
DEFAULT_BRANCH_PREFIX = '🚂'.freeze
|
|
7
17
|
DEFAULT_DEV_PORT_RANGE = (3000..3999)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require 'erb'
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Worktrees
|
|
5
|
+
# Safely updates the generated initializer to use the current managed app-config format.
|
|
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_WRAPPER_CALL = 'Rails.application.config.x.rails_worktrees.tap do |config|'.freeze
|
|
16
|
+
CONFIGURE_CALL = 'Rails::Worktrees.configure do |config|'.freeze
|
|
17
|
+
KNOWN_GUARD_FRAGMENTS = [
|
|
18
|
+
"Gem.loaded_specs.key?('rails-worktrees')",
|
|
19
|
+
'defined?(Rails::Worktrees)',
|
|
20
|
+
'Rails::Worktrees.respond_to?(:configure)'
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def self.default_content = new(content: '').send(:render_default_template)
|
|
24
|
+
|
|
25
|
+
def initialize(content:) = @content = content
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
return identical_result if current_guard_present?
|
|
29
|
+
return updated_result(self.class.default_content) if blank_content?
|
|
30
|
+
|
|
31
|
+
body = wrapped_body
|
|
32
|
+
return skip_result unless body
|
|
33
|
+
|
|
34
|
+
updated_result(rebuild_content(body))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def identical_result
|
|
40
|
+
Result.new(
|
|
41
|
+
@content,
|
|
42
|
+
false,
|
|
43
|
+
:identical,
|
|
44
|
+
['config/initializers/rails_worktrees.rb already uses the current managed initializer format.']
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def updated_result(content)
|
|
49
|
+
Result.new(
|
|
50
|
+
content,
|
|
51
|
+
content != @content,
|
|
52
|
+
:updated,
|
|
53
|
+
['Updated config/initializers/rails_worktrees.rb to use the current managed initializer format.']
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def skip_result
|
|
58
|
+
Result.new(
|
|
59
|
+
@content,
|
|
60
|
+
false,
|
|
61
|
+
:skip,
|
|
62
|
+
['config/initializers/rails_worktrees.rb is too custom to update automatically; review it manually.']
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def blank_content? = @content.to_s.strip.empty?
|
|
67
|
+
|
|
68
|
+
def current_wrapper_present?
|
|
69
|
+
!extract_current_wrapper_body(@content.to_s.strip.lines).nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def normalized_body = extract_existing_body&.then { |body| normalize_body(body) }
|
|
73
|
+
|
|
74
|
+
def extract_existing_body
|
|
75
|
+
stripped = @content.to_s.strip
|
|
76
|
+
return if stripped.empty?
|
|
77
|
+
|
|
78
|
+
body = extract_current_wrapper_body(stripped.lines)
|
|
79
|
+
return body if body
|
|
80
|
+
|
|
81
|
+
body = extract_guarded_configure_body(stripped.lines)
|
|
82
|
+
return body if body
|
|
83
|
+
|
|
84
|
+
body = extract_plain_configure_body(stripped.lines)
|
|
85
|
+
return body if body
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def extract_current_wrapper_body(lines)
|
|
91
|
+
return unless lines.first&.strip == CURRENT_WRAPPER_CALL && lines.last&.strip == 'end'
|
|
92
|
+
|
|
93
|
+
lines[1...-1].join.rstrip
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def extract_guarded_configure_body(lines)
|
|
97
|
+
return unless guarded_configure_block?(lines)
|
|
98
|
+
|
|
99
|
+
configure_index = lines.index { |line| line.strip == CONFIGURE_CALL }
|
|
100
|
+
return unless guarded_configure_lines(lines, configure_index).all? { |line| known_guard_line?(line) }
|
|
101
|
+
|
|
102
|
+
lines[(configure_index + 1)...-2].join.rstrip
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def extract_plain_configure_body(lines)
|
|
106
|
+
return unless lines.first&.strip == CONFIGURE_CALL && lines.last&.strip == 'end'
|
|
107
|
+
|
|
108
|
+
lines[1...-1].join.rstrip
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def known_guard_line?(line)
|
|
112
|
+
stripped = line.strip
|
|
113
|
+
return true if stripped.empty?
|
|
114
|
+
|
|
115
|
+
normalized = stripped.delete_suffix('&&').strip.sub(/\Aif\s+/, '')
|
|
116
|
+
KNOWN_GUARD_FRAGMENTS.include?(normalized)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def normalize_body(body)
|
|
120
|
+
body_lines = body.rstrip.lines
|
|
121
|
+
return '' if body_lines.empty?
|
|
122
|
+
|
|
123
|
+
minimum_indent = body_indent(body_lines)
|
|
124
|
+
|
|
125
|
+
body_lines.map do |line|
|
|
126
|
+
next line if line.strip.empty?
|
|
127
|
+
|
|
128
|
+
normalize_body_line(line, minimum_indent)
|
|
129
|
+
end.join.rstrip
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def rebuild_content(body)
|
|
133
|
+
content = [CURRENT_WRAPPER_CALL, 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
|
+
|
|
146
|
+
def guarded_configure_block?(lines)
|
|
147
|
+
lines.last(2).map(&:strip) == %w[end end] && lines.any? { |line| line.strip == CONFIGURE_CALL }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def guarded_configure_lines(lines, configure_index)
|
|
151
|
+
lines[0...configure_index].reject { |line| line.strip.empty? }.map(&:rstrip)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def body_indent(body_lines)
|
|
155
|
+
body_lines.reject { |line| line.strip.empty? }
|
|
156
|
+
.map { |line| line[/\A\s*/].length }
|
|
157
|
+
.min || 0
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def normalize_body_line(line, minimum_indent)
|
|
161
|
+
trimmed = line.sub(/\A\s{0,#{minimum_indent}}/, '')
|
|
162
|
+
" #{trimmed}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def current_guard_present? = current_wrapper_present?
|
|
166
|
+
|
|
167
|
+
def wrapped_body = normalized_body
|
|
168
|
+
end
|
|
169
|
+
# rubocop:enable Metrics/ClassLength
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
require 'pathname'
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Worktrees
|
|
5
|
+
# Loads project-level configuration from the generated initializer without booting the full app.
|
|
6
|
+
class ProjectConfigurationLoader
|
|
7
|
+
CURRENT_WRAPPER_CALL = 'Rails.application.config.x.rails_worktrees.tap do |config|'.freeze
|
|
8
|
+
CONFIGURE_CALL = 'Rails::Worktrees.configure do |config|'.freeze
|
|
9
|
+
INITIALIZER_RELATIVE_PATH = 'config/initializers/rails_worktrees.rb'.freeze
|
|
10
|
+
KNOWN_GUARD_FRAGMENTS = [
|
|
11
|
+
"Gem.loaded_specs.key?('rails-worktrees')",
|
|
12
|
+
'defined?(Rails::Worktrees)',
|
|
13
|
+
'Rails::Worktrees.respond_to?(:configure)'
|
|
14
|
+
].freeze
|
|
15
|
+
TEMP_RAILS_ROOT_MUTEX = Mutex.new
|
|
16
|
+
|
|
17
|
+
def initialize(root:, configuration: Rails::Worktrees.configuration)
|
|
18
|
+
@root = root
|
|
19
|
+
@configuration = configuration
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
return configuration unless initializer_path && File.file?(initializer_path)
|
|
24
|
+
|
|
25
|
+
body = extract_configuration_body(File.read(initializer_path))
|
|
26
|
+
return configuration unless body
|
|
27
|
+
|
|
28
|
+
recorder = AssignmentRecorder.new(configuration)
|
|
29
|
+
|
|
30
|
+
with_temporary_rails_root do
|
|
31
|
+
evaluate_configuration_body(body, recorder)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Rails::Worktrees.apply_application_configuration(recorder.values, configuration: configuration)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :configuration, :root
|
|
40
|
+
|
|
41
|
+
def initializer_path
|
|
42
|
+
return @initializer_path if defined?(@initializer_path)
|
|
43
|
+
|
|
44
|
+
@initializer_path = project_root&.join(INITIALIZER_RELATIVE_PATH)&.to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def project_root
|
|
48
|
+
return @project_root if defined?(@project_root)
|
|
49
|
+
|
|
50
|
+
@project_root = discover_project_root
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def discover_project_root
|
|
54
|
+
current = Pathname(root).expand_path
|
|
55
|
+
|
|
56
|
+
current.ascend do |path|
|
|
57
|
+
return path if File.file?(path.join(INITIALIZER_RELATIVE_PATH))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def evaluate_configuration_body(body, recorder)
|
|
64
|
+
# Evaluates a managed initializer body like:
|
|
65
|
+
#
|
|
66
|
+
# proc do |config|
|
|
67
|
+
# config.branch_prefix = '🌿'
|
|
68
|
+
# end
|
|
69
|
+
# rubocop:disable Security/Eval, Style/DocumentDynamicEvalDefinition, Style/EvalWithLocation
|
|
70
|
+
Kernel.eval(
|
|
71
|
+
"proc do |config|\n#{body.rstrip}\nend",
|
|
72
|
+
TOPLEVEL_BINDING,
|
|
73
|
+
initializer_path,
|
|
74
|
+
1
|
|
75
|
+
).call(recorder)
|
|
76
|
+
# rubocop:enable Security/Eval, Style/DocumentDynamicEvalDefinition, Style/EvalWithLocation
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def extract_configuration_body(content)
|
|
80
|
+
stripped = content.to_s.strip
|
|
81
|
+
return if stripped.empty?
|
|
82
|
+
|
|
83
|
+
lines = stripped.lines
|
|
84
|
+
|
|
85
|
+
extract_single_block_body(lines, CURRENT_WRAPPER_CALL) ||
|
|
86
|
+
extract_guarded_configure_body(lines) ||
|
|
87
|
+
extract_single_block_body(lines, CONFIGURE_CALL)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def extract_single_block_body(lines, opening_line)
|
|
91
|
+
return unless lines.first&.strip == opening_line && lines.last&.strip == 'end'
|
|
92
|
+
|
|
93
|
+
lines[1...-1].join
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def extract_guarded_configure_body(lines)
|
|
97
|
+
return unless guarded_configure_block?(lines)
|
|
98
|
+
|
|
99
|
+
configure_index = lines.index { |line| line.strip == CONFIGURE_CALL }
|
|
100
|
+
return unless guarded_configure_lines(lines, configure_index).all? { |line| known_guard_line?(line) }
|
|
101
|
+
|
|
102
|
+
lines[(configure_index + 1)...-2].join
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def known_guard_line?(line)
|
|
106
|
+
normalized = line.strip.delete_suffix('&&').strip.sub(/\Aif\s+/, '')
|
|
107
|
+
KNOWN_GUARD_FRAGMENTS.include?(normalized)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def guarded_configure_block?(lines)
|
|
111
|
+
lines.last(2).map(&:strip) == %w[end end] && lines.any? { |line| line.strip == CONFIGURE_CALL }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def guarded_configure_lines(lines, configure_index)
|
|
115
|
+
lines[0...configure_index].reject { |line| line.strip.empty? }.map(&:rstrip)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def with_temporary_rails_root
|
|
119
|
+
self.class::TEMP_RAILS_ROOT_MUTEX.synchronize do
|
|
120
|
+
override_state = build_rails_root_override_state
|
|
121
|
+
|
|
122
|
+
apply_temporary_rails_root(override_state, project_root)
|
|
123
|
+
yield
|
|
124
|
+
ensure
|
|
125
|
+
restore_rails_root(override_state)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_rails_root_override_state
|
|
130
|
+
had_root = Rails.respond_to?(:root)
|
|
131
|
+
{ singleton_class: Rails.singleton_class, had_root: had_root,
|
|
132
|
+
previous_root: (Rails.method(:root) if had_root), overridden: false }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def apply_temporary_rails_root(override_state, resolved_project_root)
|
|
136
|
+
override_state[:singleton_class].send(:define_method, :root) { resolved_project_root }
|
|
137
|
+
override_state[:overridden] = true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def restore_rails_root(override_state)
|
|
141
|
+
return unless override_state
|
|
142
|
+
|
|
143
|
+
override_state[:singleton_class].send(:remove_method, :root) if override_state[:overridden]
|
|
144
|
+
return unless override_state[:had_root]
|
|
145
|
+
|
|
146
|
+
override_state[:singleton_class].send(:define_method, :root, override_state[:previous_root])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Records config.<name> = value assignments without raising on unknown keys.
|
|
150
|
+
class AssignmentRecorder
|
|
151
|
+
attr_reader :values
|
|
152
|
+
|
|
153
|
+
def initialize(configuration = nil)
|
|
154
|
+
@configuration = configuration
|
|
155
|
+
@values = {}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def method_missing(method_name, *args)
|
|
159
|
+
name = method_name.to_s
|
|
160
|
+
|
|
161
|
+
if setter_call?(name, args)
|
|
162
|
+
values[name.delete_suffix('=').to_sym] = args.first
|
|
163
|
+
elsif getter_call?(name, args)
|
|
164
|
+
values.fetch(name.to_sym) { configuration_value_for(method_name) }
|
|
165
|
+
else
|
|
166
|
+
super
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
171
|
+
name = method_name.to_s
|
|
172
|
+
|
|
173
|
+
setter_name?(name) || getter_name?(name) || super
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
attr_reader :configuration
|
|
179
|
+
|
|
180
|
+
def setter_call?(name, args)
|
|
181
|
+
setter_name?(name) && args.length == 1
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def getter_call?(name, args)
|
|
185
|
+
getter_name?(name) && args.empty?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def setter_name?(name)
|
|
189
|
+
name.end_with?('=')
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def getter_name?(name)
|
|
193
|
+
!setter_name?(name)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def configuration_value_for(method_name)
|
|
197
|
+
fallback_configuration = configuration
|
|
198
|
+
return unless fallback_configuration.respond_to?(method_name)
|
|
199
|
+
|
|
200
|
+
fallback_configuration.public_send(method_name)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,328 @@
|
|
|
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 managed initializer ' \
|
|
155
|
+
'format.',
|
|
156
|
+
fixable_headline: 'config/initializers/rails_worktrees.rb can be updated automatically.',
|
|
157
|
+
warning_headline: 'config/initializers/rails_worktrees.rb needs manual review.'
|
|
158
|
+
}
|
|
159
|
+
path = absolute_path(config.fetch(:relative_path))
|
|
160
|
+
|
|
161
|
+
unless File.exist?(path)
|
|
162
|
+
return fixable_check(
|
|
163
|
+
config,
|
|
164
|
+
"#{config.fetch(:relative_path)} is missing.",
|
|
165
|
+
updated_content: InitializerUpdater.default_content,
|
|
166
|
+
apply_messages: ['Created config/initializers/rails_worktrees.rb in the current managed initializer ' \
|
|
167
|
+
'format.']
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
updater_result_check(config, InitializerUpdater.new(content: File.read(path)).call)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def database_check
|
|
175
|
+
config = {
|
|
176
|
+
identifier: :database,
|
|
177
|
+
category: :config,
|
|
178
|
+
relative_path: 'config/database.yml'
|
|
179
|
+
}
|
|
180
|
+
path = absolute_path(config.fetch(:relative_path))
|
|
181
|
+
|
|
182
|
+
unless File.exist?(path)
|
|
183
|
+
return warning_check(
|
|
184
|
+
config,
|
|
185
|
+
'config/database.yml is missing.',
|
|
186
|
+
['Add WORKTREE_DATABASE_SUFFIX support manually if this app uses a custom database setup.']
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
result = DatabaseConfigUpdater.new(content: File.read(path)).call
|
|
191
|
+
return updated_database_check(config, result) if result.changed?
|
|
192
|
+
|
|
193
|
+
if database_configured?(result)
|
|
194
|
+
return ok_check(
|
|
195
|
+
config,
|
|
196
|
+
'config/database.yml already includes WORKTREE_DATABASE_SUFFIX in supported entries.'
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
warning_check(
|
|
201
|
+
config,
|
|
202
|
+
result.messages.first || 'config/database.yml needs manual review.',
|
|
203
|
+
result.messages.drop(1)
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def updated_database_check(config, result)
|
|
208
|
+
fixable_check(
|
|
209
|
+
config,
|
|
210
|
+
'config/database.yml can be updated automatically.',
|
|
211
|
+
messages: result.messages.drop(1),
|
|
212
|
+
updated_content: result.content,
|
|
213
|
+
apply_messages: result.messages
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def database_configured?(result)
|
|
218
|
+
result.messages.first == 'Development/test database names already include WORKTREE_DATABASE_SUFFIX.'
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def procfile_config
|
|
222
|
+
{
|
|
223
|
+
identifier: :procfile,
|
|
224
|
+
category: :config,
|
|
225
|
+
relative_path: 'Procfile.dev',
|
|
226
|
+
identical_headline: 'Procfile.dev already uses the DEV_PORT-aware web entry.',
|
|
227
|
+
fixable_headline: 'Procfile.dev can be updated automatically.',
|
|
228
|
+
warning_headline: 'Procfile.dev needs manual review.'
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def puma_config
|
|
233
|
+
{
|
|
234
|
+
identifier: :puma,
|
|
235
|
+
category: :config,
|
|
236
|
+
relative_path: 'config/puma.rb',
|
|
237
|
+
identical_headline: 'config/puma.rb already prefers DEV_PORT.',
|
|
238
|
+
fixable_headline: 'config/puma.rb can be updated automatically.',
|
|
239
|
+
warning_headline: 'config/puma.rb needs manual review.'
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def optional_updater_check(config)
|
|
244
|
+
path = absolute_path(config.fetch(:relative_path))
|
|
245
|
+
return unless File.exist?(path)
|
|
246
|
+
|
|
247
|
+
updater_result_check(config, yield(File.read(path)))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def mise_check
|
|
251
|
+
relative_path = %w[mise.toml .mise.toml].find { |candidate| File.exist?(absolute_path(candidate)) }
|
|
252
|
+
return unless relative_path
|
|
253
|
+
|
|
254
|
+
config = {
|
|
255
|
+
identifier: :mise,
|
|
256
|
+
category: :config,
|
|
257
|
+
relative_path: relative_path,
|
|
258
|
+
identical_headline: "#{relative_path} already loads .env from [env].",
|
|
259
|
+
fixable_headline: "#{relative_path} can be updated automatically.",
|
|
260
|
+
warning_headline: "#{relative_path} needs manual review."
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
updater_result_check(config, mise_updater_result(relative_path))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def mise_updater_result(relative_path)
|
|
267
|
+
MiseTomlUpdater.new(
|
|
268
|
+
content: File.read(absolute_path(relative_path)),
|
|
269
|
+
file_name: File.basename(relative_path)
|
|
270
|
+
).call
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def updater_result_check(config, result)
|
|
274
|
+
case result.status
|
|
275
|
+
when :identical
|
|
276
|
+
ok_check(config, config.fetch(:identical_headline))
|
|
277
|
+
when :updated
|
|
278
|
+
fixable_check(
|
|
279
|
+
config,
|
|
280
|
+
config.fetch(:fixable_headline),
|
|
281
|
+
updated_content: result.content,
|
|
282
|
+
apply_messages: result.messages
|
|
283
|
+
)
|
|
284
|
+
else
|
|
285
|
+
warning_check(config, config.fetch(:warning_headline), result.messages)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def ok_check(config, headline)
|
|
290
|
+
build_check(config, status: :ok, headline: headline)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def warning_check(config, headline, messages = [])
|
|
294
|
+
build_check(config, status: :warning, headline: headline, messages: messages)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def fixable_check(config, headline, updated_content:, apply_messages:, **attributes)
|
|
298
|
+
build_check(
|
|
299
|
+
config,
|
|
300
|
+
status: :fixable,
|
|
301
|
+
headline: headline,
|
|
302
|
+
updated_content: updated_content,
|
|
303
|
+
apply_messages: apply_messages,
|
|
304
|
+
**attributes
|
|
305
|
+
)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def build_check(config, status:, headline:, **attributes)
|
|
309
|
+
Check.new(
|
|
310
|
+
identifier: config.fetch(:identifier),
|
|
311
|
+
category: config.fetch(:category),
|
|
312
|
+
relative_path: config.fetch(:relative_path),
|
|
313
|
+
status: status,
|
|
314
|
+
headline: headline,
|
|
315
|
+
messages: attributes.fetch(:messages, []),
|
|
316
|
+
updated_content: attributes.fetch(:updated_content, nil),
|
|
317
|
+
make_executable: attributes.fetch(:make_executable, false),
|
|
318
|
+
apply_messages: attributes.fetch(:apply_messages, [])
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def absolute_path(relative_path)
|
|
323
|
+
File.join(root, relative_path)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -5,6 +5,10 @@ module Rails
|
|
|
5
5
|
initializer 'rails_worktrees.installation_hint' do
|
|
6
6
|
Rails::Worktrees.warn_about_missing_installation
|
|
7
7
|
end
|
|
8
|
+
|
|
9
|
+
initializer 'rails_worktrees.apply_application_config', after: :load_config_initializers do |app|
|
|
10
|
+
Rails::Worktrees.apply_application_configuration(app.config.x.rails_worktrees)
|
|
11
|
+
end
|
|
8
12
|
end
|
|
9
13
|
end
|
|
10
14
|
end
|
data/lib/rails/worktrees.rb
CHANGED
|
@@ -2,14 +2,18 @@ require 'pathname'
|
|
|
2
2
|
|
|
3
3
|
require_relative 'worktrees/version'
|
|
4
4
|
require_relative 'worktrees/configuration'
|
|
5
|
+
require_relative 'worktrees/application_configuration'
|
|
5
6
|
require_relative 'worktrees/env_bootstrapper'
|
|
6
7
|
require_relative 'worktrees/command'
|
|
7
8
|
require_relative 'worktrees/cli'
|
|
8
9
|
require_relative 'worktrees/browser_command'
|
|
9
10
|
require_relative 'worktrees/database_config_updater'
|
|
11
|
+
require_relative 'worktrees/initializer_updater'
|
|
10
12
|
require_relative 'worktrees/procfile_updater'
|
|
11
13
|
require_relative 'worktrees/mise_toml_updater'
|
|
12
14
|
require_relative 'worktrees/puma_config_updater'
|
|
15
|
+
require_relative 'worktrees/project_configuration_loader'
|
|
16
|
+
require_relative 'worktrees/project_maintenance'
|
|
13
17
|
|
|
14
18
|
module Rails
|
|
15
19
|
# Rails-specific git worktree helpers and installer support.
|
|
@@ -37,6 +41,10 @@ module Rails
|
|
|
37
41
|
@configuration = Configuration.new
|
|
38
42
|
end
|
|
39
43
|
|
|
44
|
+
def apply_application_configuration(source, configuration: self.configuration)
|
|
45
|
+
ApplicationConfiguration.apply(source, configuration: configuration)
|
|
46
|
+
end
|
|
47
|
+
|
|
40
48
|
def installation_complete?(root = resolve_root)
|
|
41
49
|
return false unless root
|
|
42
50
|
|
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
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asjer Querido
|
|
@@ -55,6 +55,7 @@ files:
|
|
|
55
55
|
- lib/generators/rails/worktrees/templates/rails_worktrees.rb.tt
|
|
56
56
|
- lib/generators/worktrees/install/install_generator.rb
|
|
57
57
|
- lib/rails/worktrees.rb
|
|
58
|
+
- lib/rails/worktrees/application_configuration.rb
|
|
58
59
|
- lib/rails/worktrees/browser_command.rb
|
|
59
60
|
- lib/rails/worktrees/cli.rb
|
|
60
61
|
- lib/rails/worktrees/command.rb
|
|
@@ -66,9 +67,12 @@ files:
|
|
|
66
67
|
- lib/rails/worktrees/configuration.rb
|
|
67
68
|
- lib/rails/worktrees/database_config_updater.rb
|
|
68
69
|
- lib/rails/worktrees/env_bootstrapper.rb
|
|
70
|
+
- lib/rails/worktrees/initializer_updater.rb
|
|
69
71
|
- lib/rails/worktrees/mise_toml_updater.rb
|
|
70
72
|
- lib/rails/worktrees/names/cities.txt
|
|
71
73
|
- lib/rails/worktrees/procfile_updater.rb
|
|
74
|
+
- lib/rails/worktrees/project_configuration_loader.rb
|
|
75
|
+
- lib/rails/worktrees/project_maintenance.rb
|
|
72
76
|
- lib/rails/worktrees/puma_config_updater.rb
|
|
73
77
|
- lib/rails/worktrees/railtie.rb
|
|
74
78
|
- lib/rails/worktrees/version.rb
|