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.
@@ -1,8 +1,11 @@
1
1
  module Rails
2
2
  module Worktrees
3
3
  class Command
4
- # Resolves the default and explicit workspace path layouts.
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"), uses_default_root: true }
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 expand_home(config_root) if present_path?(config_root)
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
- nil
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[:uses_default_root]
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
- prepare_target_parent(context[:target_dir])
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
- repository = resolve_repository_context
204
- checks = project_maintenance_report(repository[:current_root]).checks + doctor_worktree_checks(repository)
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(repository)
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 #{repository[:current_root]}."
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 if step[:id] == :yarn && !yarn_lock_present?
84
+ next unless step_applicable?(step)
82
85
 
83
- result = run_step(step, dry_run: dry_run)
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)),