rails-worktrees 0.7.4 โ 0.9.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 +21 -0
- data/README.md +102 -6
- data/lib/generators/rails/worktrees/templates/bin/wt +3 -3
- data/lib/generators/rails/worktrees/templates/rails_worktrees.rb.tt +8 -0
- data/lib/generators/worktrees/install/install_generator.rb +59 -1
- data/lib/rails/worktrees/cli.rb +20 -3
- data/lib/rails/worktrees/command/in_repo_worktrees.rb +85 -0
- data/lib/rails/worktrees/command/name_picking.rb +2 -1
- data/lib/rails/worktrees/command/output.rb +15 -5
- data/lib/rails/worktrees/command/workspace_paths.rb +40 -9
- data/lib/rails/worktrees/command.rb +58 -9
- data/lib/rails/worktrees/configuration.rb +6 -2
- data/lib/rails/worktrees/pi_worktree_config_path.rb +44 -0
- data/lib/rails/worktrees/pi_worktree_config_updater.rb +123 -0
- data/lib/rails/worktrees/playwright_browser_check.rb +56 -0
- data/lib/rails/worktrees/post_create_runner.rb +45 -4
- data/lib/rails/worktrees/project_maintenance.rb +30 -3
- data/lib/rails/worktrees/teardown_runner.rb +84 -0
- data/lib/rails/worktrees/teardown_script.rb +115 -0
- data/lib/rails/worktrees/version.rb +1 -1
- data/lib/rails/worktrees.rb +4 -0
- data/sig/rails/worktrees.rbs +1 -0
- metadata +7 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
module Rails
|
|
2
2
|
module Worktrees
|
|
3
3
|
class Command
|
|
4
|
-
# Resolves the default and
|
|
4
|
+
# Resolves the sibling (default), workspace_root and in-repo worktrees_dir path layouts.
|
|
5
5
|
module WorkspacePaths
|
|
6
|
+
INVALID_WORKTREES_DIR_MESSAGE =
|
|
7
|
+
"worktrees_dir must be a relative path inside the repository, like '.worktrees' (got %p).".freeze
|
|
8
|
+
|
|
6
9
|
private
|
|
7
10
|
|
|
8
11
|
def resolve_repository_context
|
|
@@ -41,9 +44,16 @@ module Rails
|
|
|
41
44
|
|
|
42
45
|
def resolve_workspaces(repo_root, project_name)
|
|
43
46
|
explicit_root = configured_workspaces_root
|
|
44
|
-
return { root: explicit_root, uses_default_root: false } if explicit_root
|
|
47
|
+
return { root: explicit_root, layout: :workspace_root, uses_default_root: false } if explicit_root
|
|
48
|
+
|
|
49
|
+
in_repo_dir = configured_worktrees_dir
|
|
50
|
+
if in_repo_dir
|
|
51
|
+
return { root: File.join(repo_root, in_repo_dir), layout: :in_repo, relative_dir: in_repo_dir,
|
|
52
|
+
uses_default_root: false }
|
|
53
|
+
end
|
|
45
54
|
|
|
46
|
-
{ root: File.join(File.dirname(repo_root), "#{project_name}.worktrees"),
|
|
55
|
+
{ root: File.join(File.dirname(repo_root), "#{project_name}.worktrees"), layout: :sibling,
|
|
56
|
+
uses_default_root: true }
|
|
47
57
|
end
|
|
48
58
|
|
|
49
59
|
def configured_workspaces_root
|
|
@@ -51,16 +61,37 @@ module Rails
|
|
|
51
61
|
return expand_home(env_root) if present_path?(env_root)
|
|
52
62
|
|
|
53
63
|
config_root = @configuration.workspace_root
|
|
54
|
-
return
|
|
64
|
+
return unless present_path?(config_root)
|
|
65
|
+
|
|
66
|
+
if present_path?(@configuration.worktrees_dir)
|
|
67
|
+
raise Error, 'Set either workspace_root or worktrees_dir, not both.'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
expand_home(config_root)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def configured_worktrees_dir
|
|
74
|
+
value = @configuration.worktrees_dir
|
|
75
|
+
return unless present_path?(value)
|
|
55
76
|
|
|
56
|
-
|
|
77
|
+
normalized = Pathname(value.to_s).cleanpath
|
|
78
|
+
raise Error, format(INVALID_WORKTREES_DIR_MESSAGE, value) unless valid_worktrees_dir?(normalized)
|
|
79
|
+
|
|
80
|
+
normalized.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def valid_worktrees_dir?(path)
|
|
84
|
+
first_component = path.each_filename.first
|
|
85
|
+
|
|
86
|
+
path.relative? && !path.to_s.start_with?('~') &&
|
|
87
|
+
!path.each_filename.include?('..') && !['.', '.git'].include?(first_component)
|
|
57
88
|
end
|
|
58
89
|
|
|
59
90
|
def target_dir_for(project_name, worktree_name, workspaces)
|
|
60
|
-
if workspaces[:
|
|
61
|
-
File.join(workspaces[:root], worktree_name)
|
|
62
|
-
else
|
|
91
|
+
if workspaces[:layout] == :workspace_root
|
|
63
92
|
File.join(workspaces[:root], project_name, worktree_name)
|
|
93
|
+
else
|
|
94
|
+
File.join(workspaces[:root], worktree_name)
|
|
64
95
|
end
|
|
65
96
|
end
|
|
66
97
|
|
|
@@ -85,7 +116,7 @@ module Rails
|
|
|
85
116
|
end
|
|
86
117
|
|
|
87
118
|
def present_path?(path)
|
|
88
|
-
!path.nil? && !path.empty?
|
|
119
|
+
!path.nil? && !path.to_s.empty?
|
|
89
120
|
end
|
|
90
121
|
|
|
91
122
|
def expand_home(path)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require 'fileutils'
|
|
2
2
|
require_relative 'command/environment_support'
|
|
3
3
|
require_relative 'command/git_operations'
|
|
4
|
+
require_relative 'command/in_repo_worktrees'
|
|
4
5
|
require_relative 'command/name_picking'
|
|
5
6
|
require_relative 'command/output'
|
|
6
7
|
require_relative 'command/post_create_support'
|
|
@@ -15,9 +16,13 @@ module Rails
|
|
|
15
16
|
DOCTOR_SUBCOMMAND = 'doctor'.freeze
|
|
16
17
|
SETUP_SUBCOMMAND = 'setup'.freeze
|
|
17
18
|
UPDATE_SUBCOMMAND = 'update'.freeze
|
|
19
|
+
PI_FLAG_EXEMPT_META_COMMANDS = %w[-h --help help -v --version version].freeze
|
|
20
|
+
# Command words that wt never treats as a worktree name.
|
|
21
|
+
RESERVED_WORDS = %w[help version doctor setup update teardown prune remove delete].freeze
|
|
18
22
|
|
|
19
23
|
include GitOperations
|
|
20
24
|
include EnvironmentSupport
|
|
25
|
+
include InRepoWorktrees
|
|
21
26
|
include NamePicking
|
|
22
27
|
include Output
|
|
23
28
|
include PostCreateSupport
|
|
@@ -35,6 +40,7 @@ module Rails
|
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
def run
|
|
43
|
+
validate_pi_flag!
|
|
38
44
|
meta_command_result = handle_meta_command
|
|
39
45
|
return meta_command_result unless meta_command_result.nil?
|
|
40
46
|
|
|
@@ -52,10 +58,13 @@ module Rails
|
|
|
52
58
|
|
|
53
59
|
def skip_setup? = @skip_setup
|
|
54
60
|
|
|
61
|
+
def pi? = @pi
|
|
62
|
+
|
|
55
63
|
def extract_flags!
|
|
56
64
|
@dry_run = extract_flag!('--dry-run')
|
|
57
65
|
@force = extract_flag!('--force')
|
|
58
66
|
@skip_setup = extract_flag!('--skip-setup')
|
|
67
|
+
@pi = extract_flag!('--pi')
|
|
59
68
|
end
|
|
60
69
|
|
|
61
70
|
def extract_flag!(flag)
|
|
@@ -89,6 +98,13 @@ module Rails
|
|
|
89
98
|
@argv.first == UPDATE_SUBCOMMAND
|
|
90
99
|
end
|
|
91
100
|
|
|
101
|
+
def validate_pi_flag!
|
|
102
|
+
return unless pi?
|
|
103
|
+
return if update_subcommand? || PI_FLAG_EXEMPT_META_COMMANDS.include?(@argv.first)
|
|
104
|
+
|
|
105
|
+
raise Error, 'The --pi flag is only supported with wt update.'
|
|
106
|
+
end
|
|
107
|
+
|
|
92
108
|
def execute_requested_command
|
|
93
109
|
handler = requested_command_handler
|
|
94
110
|
return handler.call if handler
|
|
@@ -103,6 +119,7 @@ module Rails
|
|
|
103
119
|
when SETUP_SUBCOMMAND then -> { execute_setup_command }
|
|
104
120
|
when UPDATE_SUBCOMMAND then -> { execute_update_command }
|
|
105
121
|
when 'prune' then -> { execute_prune_command }
|
|
122
|
+
when 'teardown' then -> { execute_teardown_command }
|
|
106
123
|
else
|
|
107
124
|
-> { execute_remove_command } if remove_subcommand?
|
|
108
125
|
end
|
|
@@ -123,11 +140,16 @@ module Rails
|
|
|
123
140
|
confirm_and_remove_target(context[:target_dir])
|
|
124
141
|
end
|
|
125
142
|
|
|
126
|
-
|
|
143
|
+
prepare_worktree_location(context)
|
|
127
144
|
attach_or_create_worktree(context)
|
|
128
145
|
finish(context)
|
|
129
146
|
end
|
|
130
147
|
|
|
148
|
+
def prepare_worktree_location(context)
|
|
149
|
+
ensure_worktrees_dir_ignored(context)
|
|
150
|
+
prepare_target_parent(context[:target_dir])
|
|
151
|
+
end
|
|
152
|
+
|
|
131
153
|
def execute_setup_command
|
|
132
154
|
validate_setup_args!
|
|
133
155
|
announce_dry_run if dry_run?
|
|
@@ -138,6 +160,25 @@ module Rails
|
|
|
138
160
|
complete_setup_flow(context, success_message: 'Setup complete')
|
|
139
161
|
end
|
|
140
162
|
|
|
163
|
+
def execute_teardown_command
|
|
164
|
+
raise Error, 'Usage: wt teardown [--dry-run]' unless @argv.length == 1 && !force? && !skip_setup?
|
|
165
|
+
|
|
166
|
+
require_git_repo
|
|
167
|
+
repository = resolve_repository_context
|
|
168
|
+
if repository[:current_root] == repository[:primary_root]
|
|
169
|
+
raise Error, 'Cannot teardown the main checkout. Run wt teardown inside a linked worktree.'
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
teardown_worktree(repository[:current_root], use_current_env: true)
|
|
173
|
+
success(dry_run? ? 'Dry run complete' : 'Teardown complete')
|
|
174
|
+
0
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def teardown_worktree(target_dir, use_current_env: false)
|
|
178
|
+
TeardownRunner.new(target_dir: target_dir, env: @env, io: { stdout: @stdout },
|
|
179
|
+
use_current_env: use_current_env).call(dry_run: dry_run?)
|
|
180
|
+
end
|
|
181
|
+
|
|
141
182
|
def execute_remove_command
|
|
142
183
|
require_git_repo
|
|
143
184
|
announce_dry_run if dry_run?
|
|
@@ -147,6 +188,7 @@ module Rails
|
|
|
147
188
|
removal_status = removal_status_for(context)
|
|
148
189
|
|
|
149
190
|
ensure_removable!(context, **removal_status)
|
|
191
|
+
teardown_worktree(context[:target_dir]) if removal_status[:worktree_exists]
|
|
150
192
|
return complete_remove_dry_run(context, **removal_status) if dry_run?
|
|
151
193
|
|
|
152
194
|
perform_remove(context, **removal_status)
|
|
@@ -200,8 +242,8 @@ module Rails
|
|
|
200
242
|
return 1
|
|
201
243
|
end
|
|
202
244
|
|
|
203
|
-
|
|
204
|
-
checks = project_maintenance_report(
|
|
245
|
+
current_root = canonical_path(git_capture('rev-parse', '--show-toplevel').strip)
|
|
246
|
+
checks = project_maintenance_report(current_root).checks + doctor_worktree_checks(current_root)
|
|
205
247
|
print_doctor_report(checks)
|
|
206
248
|
|
|
207
249
|
checks.any? { |check| check.fixable? || check.warning? } ? 1 : 0
|
|
@@ -259,7 +301,7 @@ module Rails
|
|
|
259
301
|
end
|
|
260
302
|
|
|
261
303
|
def validate_update_args!
|
|
262
|
-
raise Error, 'Usage: wt update [--dry-run] [path]' unless [1, 2].include?(@argv.length)
|
|
304
|
+
raise Error, 'Usage: wt update [--dry-run] [--pi] [path]' unless [1, 2].include?(@argv.length)
|
|
263
305
|
raise Error, 'The --force flag is only supported with wt remove.' if force?
|
|
264
306
|
raise Error, 'The --skip-setup flag is only supported when creating a worktree.' if skip_setup?
|
|
265
307
|
end
|
|
@@ -638,19 +680,26 @@ module Rails
|
|
|
638
680
|
end
|
|
639
681
|
|
|
640
682
|
def project_maintenance_report(root)
|
|
641
|
-
::Rails::Worktrees::ProjectMaintenance.new(root: root).call
|
|
683
|
+
::Rails::Worktrees::ProjectMaintenance.new(root: root, pi_worktree: pi?).call
|
|
642
684
|
end
|
|
643
685
|
|
|
644
|
-
def doctor_worktree_checks(
|
|
686
|
+
def doctor_worktree_checks(current_root)
|
|
645
687
|
[
|
|
646
688
|
doctor_check(
|
|
647
689
|
category: :git,
|
|
648
690
|
status: :ok,
|
|
649
|
-
headline: "Git repository detected at #{
|
|
691
|
+
headline: "Git repository detected at #{current_root}."
|
|
650
692
|
),
|
|
651
693
|
default_branch_doctor_check,
|
|
652
|
-
stale_worktree_doctor_check
|
|
653
|
-
|
|
694
|
+
stale_worktree_doctor_check,
|
|
695
|
+
worktree_layout_doctor_check
|
|
696
|
+
].compact
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def worktree_layout_doctor_check
|
|
700
|
+
in_repo_worktrees_doctor_check(resolve_repository_context)
|
|
701
|
+
rescue Error => e
|
|
702
|
+
doctor_check(category: :worktree, status: :warning, headline: e.message)
|
|
654
703
|
end
|
|
655
704
|
|
|
656
705
|
def default_branch_doctor_check
|
|
@@ -5,6 +5,7 @@ module Rails
|
|
|
5
5
|
CONFIGURABLE_ATTRIBUTES = %i[
|
|
6
6
|
bootstrap_env
|
|
7
7
|
workspace_root
|
|
8
|
+
worktrees_dir
|
|
8
9
|
dev_port_range
|
|
9
10
|
branch_prefix
|
|
10
11
|
name_sources_path
|
|
@@ -13,6 +14,7 @@ module Rails
|
|
|
13
14
|
post_create_command
|
|
14
15
|
run_bundle_install
|
|
15
16
|
run_yarn_install
|
|
17
|
+
run_playwright_install
|
|
16
18
|
run_db_prepare
|
|
17
19
|
run_test_db_prepare
|
|
18
20
|
run_test_assets_precompile
|
|
@@ -33,10 +35,10 @@ module Rails
|
|
|
33
35
|
DEFAULT_WORKTREE_DATABASE_SUFFIX_MAX_LENGTH = 18
|
|
34
36
|
|
|
35
37
|
attr_accessor :bootstrap_env, :branch_prefix, :dev_port_range, :legacy_used_names_files,
|
|
36
|
-
:name_sources_path, :used_names_file, :workspace_root,
|
|
38
|
+
:name_sources_path, :used_names_file, :workspace_root, :worktrees_dir,
|
|
37
39
|
:worktree_database_suffix_max_length,
|
|
38
40
|
:post_create_command,
|
|
39
|
-
:run_bundle_install, :run_yarn_install,
|
|
41
|
+
:run_bundle_install, :run_yarn_install, :run_playwright_install,
|
|
40
42
|
:run_db_prepare, :run_test_db_prepare, :run_test_assets_precompile,
|
|
41
43
|
:link_credential_keys, :link_test_credential_key, :link_production_credential_key
|
|
42
44
|
|
|
@@ -50,6 +52,7 @@ module Rails
|
|
|
50
52
|
def assign_core_defaults
|
|
51
53
|
@bootstrap_env = DEFAULT_BOOTSTRAP_ENV
|
|
52
54
|
@workspace_root = nil
|
|
55
|
+
@worktrees_dir = nil
|
|
53
56
|
@branch_prefix = DEFAULT_BRANCH_PREFIX
|
|
54
57
|
@dev_port_range = DEFAULT_DEV_PORT_RANGE
|
|
55
58
|
@name_sources_path = DEFAULT_NAME_SOURCES_PATH
|
|
@@ -62,6 +65,7 @@ module Rails
|
|
|
62
65
|
@post_create_command = nil
|
|
63
66
|
@run_bundle_install = true
|
|
64
67
|
@run_yarn_install = true
|
|
68
|
+
@run_playwright_install = true
|
|
65
69
|
@run_db_prepare = true
|
|
66
70
|
@run_test_db_prepare = true
|
|
67
71
|
@run_test_assets_precompile = true
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Worktrees
|
|
3
|
+
# Decides whether .pi/worktree.json under an app root can be safely read and written in place.
|
|
4
|
+
class PiWorktreeConfigPath
|
|
5
|
+
RELATIVE_PATH = '.pi/worktree.json'.freeze
|
|
6
|
+
|
|
7
|
+
def self.conflict(root)
|
|
8
|
+
new(root).conflict
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(root)
|
|
12
|
+
@root = root
|
|
13
|
+
@path = File.join(root, RELATIVE_PATH)
|
|
14
|
+
@parent = File.dirname(@path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns why the config path is unsafe, or nil when it can be used.
|
|
18
|
+
def conflict
|
|
19
|
+
return 'is a symlink' if File.symlink?(@path)
|
|
20
|
+
return 'is not a regular file' if File.exist?(@path) && !File.file?(@path)
|
|
21
|
+
return "cannot be created because #{File.basename(@parent)} is not a directory" if parent_not_directory?
|
|
22
|
+
return if within_root?(nearest_existing_realpath(@parent))
|
|
23
|
+
|
|
24
|
+
'resolves outside the app through a symlinked directory'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def parent_not_directory?
|
|
30
|
+
(File.exist?(@parent) || File.symlink?(@parent)) && !File.directory?(@parent)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def nearest_existing_realpath(path)
|
|
34
|
+
path = File.dirname(path) until File.exist?(path)
|
|
35
|
+
File.realpath(path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def within_root?(path)
|
|
39
|
+
root = File.realpath(@root)
|
|
40
|
+
path == root || path.start_with?("#{root}#{File::SEPARATOR}")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require_relative 'pi_worktree_config_path'
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Worktrees
|
|
6
|
+
# Safely merges the bin/wt setup hook into the pi-worktree extension's .pi/worktree.json.
|
|
7
|
+
class PiWorktreeConfigUpdater
|
|
8
|
+
Result = Struct.new(:content, :changed, :status, :messages) do
|
|
9
|
+
def changed?
|
|
10
|
+
changed
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
RELATIVE_PATH = PiWorktreeConfigPath::RELATIVE_PATH
|
|
15
|
+
SETUP_COMMAND = 'bin/wt setup'.freeze
|
|
16
|
+
# Horizontal whitespace only: a newline would make the shell run two separate commands.
|
|
17
|
+
SETUP_COMMAND_PATTERN = %r{\A[ \t]*(?:\./)?bin/wt[ \t]+setup[ \t]*\z}
|
|
18
|
+
|
|
19
|
+
def self.configured?(content)
|
|
20
|
+
return false if content.nil? || !content.valid_encoding?
|
|
21
|
+
|
|
22
|
+
config = JSON.parse(content)
|
|
23
|
+
config.is_a?(Hash) && setup_hook?(config['postCreate'])
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.setup_hook?(commands)
|
|
29
|
+
return false unless commands.is_a?(Array)
|
|
30
|
+
|
|
31
|
+
commands.any? { |command| command.is_a?(String) && command.match?(SETUP_COMMAND_PATTERN) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(content:)
|
|
35
|
+
@content = content
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
config = parse_config
|
|
40
|
+
return config if config.is_a?(Result)
|
|
41
|
+
|
|
42
|
+
conflict = conflict_message(config)
|
|
43
|
+
return conflict_result(conflict) if conflict
|
|
44
|
+
|
|
45
|
+
merge_setup_hook(config)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def parse_config
|
|
51
|
+
return {} if @content.nil?
|
|
52
|
+
unless @content.valid_encoding?
|
|
53
|
+
return conflict_result("#{RELATIVE_PATH} is not valid UTF-8; leaving it unchanged.")
|
|
54
|
+
end
|
|
55
|
+
return {} if @content.strip.empty?
|
|
56
|
+
|
|
57
|
+
config = JSON.parse(@content)
|
|
58
|
+
return config if config.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
conflict_result("#{RELATIVE_PATH} must contain a JSON object; leaving it unchanged.")
|
|
61
|
+
rescue JSON::ParserError => e
|
|
62
|
+
conflict_result("#{RELATIVE_PATH} is not valid JSON (#{e.message.lines.first.strip}); leaving it unchanged.")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def conflict_message(config)
|
|
66
|
+
if config.key?('linkEnvFiles') && config['linkEnvFiles'] != false
|
|
67
|
+
return [
|
|
68
|
+
"#{RELATIVE_PATH} sets \"linkEnvFiles\": #{JSON.generate(config['linkEnvFiles'])}, so pi-worktree " \
|
|
69
|
+
"would symlink the main checkout's .env into new worktrees before bin/wt setup runs.",
|
|
70
|
+
'Set "linkEnvFiles": false yourself to give each worktree its own DEV_PORT and ' \
|
|
71
|
+
'WORKTREE_DATABASE_SUFFIX, then rerun this command; the file was left unchanged.'
|
|
72
|
+
]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
return if !config.key?('postCreate') || string_list?(config['postCreate'])
|
|
76
|
+
|
|
77
|
+
"#{RELATIVE_PATH} postCreate must be a list of shell commands; leaving it unchanged."
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def merge_setup_hook(config)
|
|
81
|
+
updated = config.dup
|
|
82
|
+
messages = [add_setup_hook(updated), disable_env_file_linking(updated)].compact
|
|
83
|
+
return identical_result if messages.empty?
|
|
84
|
+
|
|
85
|
+
Result.new("#{JSON.pretty_generate(ordered_new_config(config, updated))}\n", true, :updated, messages)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def add_setup_hook(config)
|
|
89
|
+
return if self.class.setup_hook?(config['postCreate'])
|
|
90
|
+
|
|
91
|
+
config['postCreate'] = Array(config['postCreate']) + [SETUP_COMMAND]
|
|
92
|
+
"Added \"#{SETUP_COMMAND}\" to postCreate in #{RELATIVE_PATH}."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def disable_env_file_linking(config)
|
|
96
|
+
return if config.key?('linkEnvFiles')
|
|
97
|
+
|
|
98
|
+
config['linkEnvFiles'] = false
|
|
99
|
+
"Disabled linkEnvFiles in #{RELATIVE_PATH} so each worktree keeps its own .env."
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Fresh configs list linkEnvFiles first so the safety switch is visible above the hooks.
|
|
103
|
+
def ordered_new_config(original, updated)
|
|
104
|
+
return updated unless original.empty?
|
|
105
|
+
|
|
106
|
+
{ 'linkEnvFiles' => updated['linkEnvFiles'], 'postCreate' => updated['postCreate'] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def string_list?(value)
|
|
110
|
+
value.is_a?(Array) && value.all?(String)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def identical_result
|
|
114
|
+
Result.new(@content, false, :identical,
|
|
115
|
+
["#{RELATIVE_PATH} already runs bin/wt setup with env-file linking disabled."])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def conflict_result(messages)
|
|
119
|
+
Result.new(@content, false, :conflict, Array(messages))
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'open3'
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Worktrees
|
|
6
|
+
# Decides whether a worktree needs the Playwright Chromium build that matches
|
|
7
|
+
# the playwright package pinned in its package.json.
|
|
8
|
+
class PlaywrightBrowserCheck
|
|
9
|
+
PACKAGES = %w[playwright @playwright/test].freeze
|
|
10
|
+
DEPENDENCY_SECTIONS = %w[dependencies devDependencies].freeze
|
|
11
|
+
CLI_PATH = 'node_modules/.bin/playwright'.freeze
|
|
12
|
+
BROWSER = 'chromium'.freeze
|
|
13
|
+
INSTALL_LOCATION = /^\s*Install location:\s*(.+?)\s*$/
|
|
14
|
+
# Playwright writes this marker once a browser download has been fully extracted.
|
|
15
|
+
INSTALLATION_MARKER = 'INSTALLATION_COMPLETE'.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(target_dir:, env:)
|
|
18
|
+
@target_dir = target_dir
|
|
19
|
+
@env = env
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def applicable?
|
|
23
|
+
package = JSON.parse(File.read(File.join(@target_dir, 'package.json')))
|
|
24
|
+
DEPENDENCY_SECTIONS.any? do |section|
|
|
25
|
+
dependencies = package[section]
|
|
26
|
+
dependencies.is_a?(Hash) && PACKAGES.any? { |name| dependencies.key?(name) }
|
|
27
|
+
end
|
|
28
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cli_present?
|
|
33
|
+
File.exist?(File.join(@target_dir, CLI_PATH))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns false whenever the installed state cannot be proven.
|
|
37
|
+
def installed?
|
|
38
|
+
locations = install_locations
|
|
39
|
+
!locations.empty? && locations.all? { |location| File.file?(File.join(location, INSTALLATION_MARKER)) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def install_locations
|
|
45
|
+
return [] unless cli_present?
|
|
46
|
+
|
|
47
|
+
output, status = Open3.capture2e(@env, CLI_PATH, 'install', '--dry-run', BROWSER, chdir: @target_dir)
|
|
48
|
+
return [] unless status.success?
|
|
49
|
+
|
|
50
|
+
output.scan(INSTALL_LOCATION).flatten
|
|
51
|
+
rescue SystemCallError
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -3,7 +3,7 @@ require 'open3'
|
|
|
3
3
|
module Rails
|
|
4
4
|
module Worktrees
|
|
5
5
|
# Runs post-create setup steps in a newly created worktree.
|
|
6
|
-
# Steps run in order: credential linking, bundle install, yarn install,
|
|
6
|
+
# Steps run in order: credential linking, bundle install, yarn install, Playwright Chromium install,
|
|
7
7
|
# db:prepare (development), db:prepare (test), assets:precompile (test), assets:clobber.
|
|
8
8
|
# rubocop:disable Metrics/ClassLength
|
|
9
9
|
class PostCreateRunner
|
|
@@ -12,6 +12,8 @@ module Rails
|
|
|
12
12
|
header: '๐ฆ Installing gems...' },
|
|
13
13
|
{ id: :yarn, argv: %w[yarn install],
|
|
14
14
|
header: '๐งถ Installing JS dependencies...' },
|
|
15
|
+
{ id: :playwright, argv: [PlaywrightBrowserCheck::CLI_PATH, 'install', PlaywrightBrowserCheck::BROWSER],
|
|
16
|
+
header: '๐ญ Installing Playwright Chromium...' },
|
|
15
17
|
{ id: :db_prepare, argv: %w[bin/rails db:prepare],
|
|
16
18
|
header: '๐๏ธ Preparing development database...', env: { 'RAILS_ENV' => 'development' } },
|
|
17
19
|
{ id: :test_db_prepare, argv: %w[bin/rails db:prepare],
|
|
@@ -25,6 +27,7 @@ module Rails
|
|
|
25
27
|
STEP_CONFIG = {
|
|
26
28
|
bundle: :run_bundle_install,
|
|
27
29
|
yarn: :run_yarn_install,
|
|
30
|
+
playwright: :run_playwright_install,
|
|
28
31
|
db_prepare: :run_db_prepare,
|
|
29
32
|
test_db_prepare: :run_test_db_prepare,
|
|
30
33
|
test_assets_precompile: :run_test_assets_precompile,
|
|
@@ -78,15 +81,52 @@ module Rails
|
|
|
78
81
|
STEPS.each do |step|
|
|
79
82
|
config_attr = STEP_CONFIG[step[:id]]
|
|
80
83
|
next unless @configuration.public_send(config_attr)
|
|
81
|
-
next
|
|
84
|
+
next unless step_applicable?(step)
|
|
82
85
|
|
|
83
|
-
result =
|
|
86
|
+
result = run_built_in_step(step, dry_run: dry_run)
|
|
84
87
|
return result unless result.zero?
|
|
85
88
|
end
|
|
86
89
|
|
|
87
90
|
0
|
|
88
91
|
end
|
|
89
92
|
|
|
93
|
+
def step_applicable?(step)
|
|
94
|
+
case step[:id]
|
|
95
|
+
when :yarn then yarn_lock_present?
|
|
96
|
+
when :playwright then yarn_lock_present? && playwright_browser_check.applicable?
|
|
97
|
+
else true
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def run_built_in_step(step, dry_run:)
|
|
102
|
+
return run_playwright_step(step, dry_run: dry_run) if step[:id] == :playwright
|
|
103
|
+
|
|
104
|
+
run_step(step, dry_run: dry_run)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Always runs the real installer: it skips cached downloads itself and registers this
|
|
108
|
+
# worktree in the shared browser cache, so a later cleanup keeps its browsers.
|
|
109
|
+
def run_playwright_step(step, dry_run:)
|
|
110
|
+
check = playwright_browser_check
|
|
111
|
+
return preview_playwright_step(step, check) if dry_run
|
|
112
|
+
|
|
113
|
+
unless check.cli_present?
|
|
114
|
+
info("โ ๏ธ Skipping Playwright Chromium: #{PlaywrightBrowserCheck::CLI_PATH} was not found.")
|
|
115
|
+
return 0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
run_step(step, dry_run: false)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def preview_playwright_step(step, check)
|
|
122
|
+
info('๐ญ Playwright Chromium is already installed; no download needed.') if check.installed?
|
|
123
|
+
run_step(step, dry_run: true)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def playwright_browser_check
|
|
127
|
+
PlaywrightBrowserCheck.new(target_dir: @target_dir, env: runtime_env)
|
|
128
|
+
end
|
|
129
|
+
|
|
90
130
|
def run_credential_linking(dry_run:)
|
|
91
131
|
# Missing peer key files are intentionally best-effort and should not abort
|
|
92
132
|
# worktree setup; the linker reports those cases in its messages while real
|
|
@@ -158,7 +198,8 @@ module Rails
|
|
|
158
198
|
'BUNDLE_GEMFILE', 'BUNDLE_PATH', 'GEM_HOME', 'GEM_PATH',
|
|
159
199
|
'RUBY_VERSION', 'RAILS_ENV', 'NODE_ENV',
|
|
160
200
|
'XDG_STATE_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME',
|
|
161
|
-
'DEV_PORT', 'WORKTREE_DATABASE_SUFFIX'
|
|
201
|
+
'DEV_PORT', 'WORKTREE_DATABASE_SUFFIX',
|
|
202
|
+
'PLAYWRIGHT_BROWSERS_PATH')
|
|
162
203
|
end
|
|
163
204
|
|
|
164
205
|
attr_reader :runtime_env
|
|
@@ -50,8 +50,9 @@ module Rails
|
|
|
50
50
|
TEMPLATE_ROOT = File.expand_path('../../generators/rails/worktrees/templates', __dir__)
|
|
51
51
|
FILE_ENCODING = 'UTF-8'.freeze
|
|
52
52
|
|
|
53
|
-
def initialize(root:)
|
|
53
|
+
def initialize(root:, pi_worktree: false)
|
|
54
54
|
@root = root
|
|
55
|
+
@pi_worktree = pi_worktree
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def call
|
|
@@ -60,7 +61,7 @@ module Rails
|
|
|
60
61
|
|
|
61
62
|
private
|
|
62
63
|
|
|
63
|
-
attr_reader :root
|
|
64
|
+
attr_reader :root, :pi_worktree
|
|
64
65
|
|
|
65
66
|
def maintenance_checks
|
|
66
67
|
[
|
|
@@ -70,7 +71,8 @@ module Rails
|
|
|
70
71
|
optional_updater_check(procfile_config) { |content| ProcfileUpdater.new(content: content).call },
|
|
71
72
|
optional_updater_check(puma_config) { |content| PumaConfigUpdater.new(content: content).call },
|
|
72
73
|
mise_check,
|
|
73
|
-
template_file_check(browser_wrapper_config)
|
|
74
|
+
template_file_check(browser_wrapper_config),
|
|
75
|
+
pi_worktree_check
|
|
74
76
|
]
|
|
75
77
|
end
|
|
76
78
|
|
|
@@ -263,6 +265,31 @@ module Rails
|
|
|
263
265
|
updater_result_check(config, mise_updater_result(relative_path))
|
|
264
266
|
end
|
|
265
267
|
|
|
268
|
+
# Only audit .pi/worktree.json when explicitly requested or when it already runs bin/wt setup,
|
|
269
|
+
# so configs owned purely by other pi-worktree users stay untouched.
|
|
270
|
+
def pi_worktree_check
|
|
271
|
+
relative_path = PiWorktreeConfigUpdater::RELATIVE_PATH
|
|
272
|
+
path = absolute_path(relative_path)
|
|
273
|
+
content = File.file?(path) ? read_text(path) : nil
|
|
274
|
+
return unless pi_worktree || PiWorktreeConfigUpdater.configured?(content)
|
|
275
|
+
|
|
276
|
+
config = {
|
|
277
|
+
identifier: :pi_worktree,
|
|
278
|
+
category: :config,
|
|
279
|
+
relative_path: relative_path,
|
|
280
|
+
identical_headline: "#{relative_path} already runs bin/wt setup with env-file linking disabled.",
|
|
281
|
+
fixable_headline: "#{relative_path} can be updated automatically.",
|
|
282
|
+
warning_headline: "#{relative_path} needs manual review."
|
|
283
|
+
}
|
|
284
|
+
path_conflict = PiWorktreeConfigPath.conflict(root)
|
|
285
|
+
if path_conflict
|
|
286
|
+
return warning_check(config, config.fetch(:warning_headline),
|
|
287
|
+
["#{relative_path} #{path_conflict}; leaving it unchanged."])
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
updater_result_check(config, PiWorktreeConfigUpdater.new(content: content).call)
|
|
291
|
+
end
|
|
292
|
+
|
|
266
293
|
def mise_updater_result(relative_path)
|
|
267
294
|
MiseTomlUpdater.new(
|
|
268
295
|
content: read_text(absolute_path(relative_path)),
|