rails-worktrees 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19ad567125176f17f962138eaf218f8e8c91a7a6585b61db0bddfa4c259f7d17
4
- data.tar.gz: 7c29590624ed404c362deb38866f134ba9516bbd3e6f83f3b04cfb00792caeb4
3
+ metadata.gz: 46ec0bdd9361eabe4925e37760bcd5d050c8f71072ec93cfdb427e244186ea8e
4
+ data.tar.gz: 965a2ea5c6d454739f833cf0a534a4b195903531d159e2fb25f5eff9bb71083c
5
5
  SHA512:
6
- metadata.gz: f35103be9855cc4e262f12b4dbff0ff935cb65b67d3ae6d2ca619eb1d47bdfcf8829be5b03f2d8ff3cc587509098045d32ccea539fa48395a02c6180ca69546b
7
- data.tar.gz: ef54f096ea64e518519b59f9f74af756bec7601ee492c0d1aec98736c485e62998cc6ec9149b251fa903f4acdb39f5e8cc36e4ed66aa650357df76090c3fd67b
6
+ metadata.gz: fa02fa0f6786fb3865326f8cef1e4beb804a25411cc7b5d15676236424b23a5cd4e5b55bd21550f1b1e3f422384f1c06662ff05b9d40e291e931b1bd09775af2
7
+ data.tar.gz: 43269ef67990a1a4986f407a96696b9b01faabf8a64930e676c3c505a214d38b228ba3dae5e9ffa76fc74dc59d96bbdad3eafceb42c99236c64a23954fbefced
@@ -1 +1 @@
1
- {".":"0.2.2"}
1
+ {".":"0.3.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/asjer/rails-worktrees/compare/v0.2.2...v0.3.0) (2026-03-30)
4
+
5
+
6
+ ### Features
7
+
8
+ * **ob:** add `bin/ob` cli for opening `localhost:$DEV_PORT` routes in the browser ([1c94ade](https://github.com/asjer/rails-worktrees/commit/1c94adebd338cfe6e344095f3d6df089570f1e82))
9
+
3
10
  ## [0.2.2](https://github.com/asjer/rails-worktrees/compare/v0.2.1...v0.2.2) (2026-03-30)
4
11
 
5
12
 
@@ -42,6 +49,7 @@
42
49
  ## [Unreleased]
43
50
 
44
51
  - Add a gem-managed `wt` CLI for creating Rails worktrees.
52
+ - Add an optional gem-managed `ob` CLI plus generated `bin/ob` wrapper for opening `localhost:$DEV_PORT` routes.
45
53
  - Add a Rails installer generator that creates `bin/wt` and `config/initializers/rails_worktrees.rb`.
46
54
  - Add conservative `config/database.yml` patching for common development/test database names.
47
55
  - Add a manual-dispatch GitHub Actions workflow for the disposable Rails smoke test.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Rails::Worktrees
2
2
 
3
- `rails-worktrees` adds a Rails-friendly `bin/wt` command for creating Git worktrees with isolated development and test databases.
3
+ `rails-worktrees` adds Rails-friendly `bin/wt` and `bin/ob` commands for creating Git worktrees and opening the right local browser URL for each worktree.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -13,6 +13,8 @@
13
13
  ```bash
14
14
  bundle add rails-worktrees
15
15
  bin/rails generate worktrees:install
16
+ # or, to also generate bin/ob without the yolo follow-ups:
17
+ bin/rails generate worktrees:install --browser
16
18
  # or, to apply the common Procfile.dev + Puma + mise follow-ups automatically:
17
19
  bin/rails generate worktrees:install --yolo
18
20
  ```
@@ -20,12 +22,14 @@ bin/rails generate worktrees:install --yolo
20
22
  The installer adds:
21
23
 
22
24
  - `bin/wt` — a thin wrapper that executes the gem-owned CLI
25
+ - `bin/ob` — an optional browser helper generated by `--browser` or `--yolo`
23
26
  - `config/initializers/rails_worktrees.rb` — optional configuration
24
27
  - `Procfile.dev.worktree.example` — a copy-paste helper for `${DEV_PORT:-3000}` in `Procfile.dev` on regular installs
25
28
  - a safe update to `config/database.yml` for common development/test database names
26
29
 
27
30
  With `--yolo`, the installer also:
28
31
 
32
+ - generates `bin/ob`
29
33
  - skips `Procfile.dev.worktree.example`
30
34
  - replaces the existing `web:` entry in `Procfile.dev` with the DEV_PORT-aware command when `Procfile.dev` already exists
31
35
  - updates `config/puma.rb` to use `port ENV['DEV_PORT'] || ENV.fetch('PORT', 3000)` when it still uses a supported default `PORT` binding
@@ -38,6 +42,11 @@ bin/wt # auto-pick a name from bundled *.txt lists
38
42
  bin/wt my-feature # use an explicit worktree name
39
43
  bin/wt --dry-run my-feature # preview the full setup without changing anything
40
44
  bin/wt --print-env my-feature # preview DEV_PORT and WORKTREE_DATABASE_SUFFIX
45
+
46
+ bin/ob # open http://localhost:$DEV_PORT/
47
+ bin/ob contact # open http://localhost:$DEV_PORT/contact
48
+ bin/ob '/contact?from=nav' # open a local route with query params
49
+ bin/ob --print-url '?from=nav' # print the resolved URL without opening a browser
41
50
  ```
42
51
 
43
52
  ### Options
@@ -124,6 +133,33 @@ When `bin/wt` creates a worktree it writes a worktree-local `.env` with:
124
133
 
125
134
  Existing `.env` values are never overwritten.
126
135
 
136
+ ### Browser helper
137
+
138
+ If you install with `--browser` or `--yolo`, the installer generates `bin/ob`.
139
+
140
+ `bin/ob` reads `DEV_PORT` from the current app or worktree's `.env`, falls back to `ENV['DEV_PORT']`, then falls back to `3000`, and opens `http://localhost:$DEV_PORT/...` in your default browser.
141
+
142
+ Route examples:
143
+
144
+ ```bash
145
+ bin/ob
146
+ bin/ob contact
147
+ bin/ob admin/users
148
+ bin/ob '/contact?from=footer'
149
+ bin/ob --print-url '?from=footer'
150
+ ```
151
+
152
+ `bin/ob` accepts a single optional local route argument. Query-only routes such as `?from=footer` resolve to the app root, and full URLs such as `https://example.com` are intentionally rejected.
153
+
154
+ If `DEV_PORT` is missing from `.env`, `bin/ob` tells you when it falls back to `ENV['DEV_PORT']` or `3000`.
155
+
156
+ Browser opening currently uses:
157
+
158
+ - `open` on macOS
159
+ - `xdg-open` on Linux and other Unix-like environments that provide it
160
+
161
+ For scripts and debugging, `bin/ob --print-url [route]` prints the resolved localhost URL without opening a browser.
162
+
127
163
  By default, the installer does **not** edit your `Procfile.dev`, `config/puma.rb`, or `mise` config. It generates `Procfile.dev.worktree.example` with a ready-to-copy line:
128
164
 
129
165
  ```text
@@ -160,7 +196,7 @@ This smoke test:
160
196
  - creates a temporary Rails app from a compatible Rails version
161
197
  - installs `rails-worktrees` from the current checkout path
162
198
  - runs `bin/rails generate worktrees:install --yolo`
163
- - verifies `bin/wt`, the generated initializer, that `--yolo` skips the Procfile example, yolo updates to `Procfile.dev`, `config/puma.rb`, and `mise.toml`, `config/database.yml` patching, and worktree `.env` bootstrapping
199
+ - verifies `bin/wt`, `bin/ob`, the generated initializer, that `--yolo` skips the Procfile example, yolo updates to `Procfile.dev`, `config/puma.rb`, and `mise.toml`, `config/database.yml` patching, and worktree `.env` bootstrapping
164
200
  - creates a temporary bare `origin` and confirms `bin/wt smoke-branch` creates a real worktree
165
201
 
166
202
  By default, the script cleans up all temp directories after the run. Set `KEEP_SMOKE_TEST_ARTIFACTS=1` to keep them around for debugging, or set `RAILS_WORKTREES_SMOKE_RAILS_VERSION` to try a different compatible Rails version.
data/exe/ob ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ lib = File.expand_path('../lib', __dir__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ require 'rails/worktrees'
6
+
7
+ exit(
8
+ Rails::Worktrees::BrowserCommand.new(
9
+ argv: ARGV,
10
+ io: { stdin: $stdin, stdout: $stdout, stderr: $stderr },
11
+ env: ENV,
12
+ cwd: Dir.pwd
13
+ ).run
14
+ )
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
3
+ ENV['RAILS_WORKTREES_APP_ROOT'] ||= File.expand_path('..', __dir__)
4
+
5
+ require 'bundler/setup'
6
+ load Gem.bin_path('rails-worktrees', 'ob')
@@ -17,12 +17,24 @@ module Worktrees
17
17
  include ::Rails::Worktrees::Generators::PumaFollowUp
18
18
 
19
19
  namespace 'worktrees:install'
20
- desc 'Installs bin/wt, a Rails::Worktrees initializer, and updates config/database.yml when safe.'
20
+ desc [
21
+ 'Installs bin/wt, optional bin/ob, a Rails::Worktrees initializer,',
22
+ 'and updates config/database.yml when safe.'
23
+ ].join(' ')
21
24
  source_root File.expand_path('../../rails/worktrees/templates', __dir__)
22
25
  class_option :conductor, type: :boolean, default: false,
23
26
  desc: 'Configure the installer for ~/Sites/conductor/workspaces'
24
- class_option :yolo, type: :boolean, default: false,
25
- desc: 'Apply common Procfile.dev, config/puma.rb, and mise .env follow-up edits when safe'
27
+ class_option :browser,
28
+ type: :boolean,
29
+ default: false,
30
+ desc: 'Generate bin/ob to open localhost:$DEV_PORT routes for this app/worktree'
31
+ class_option :yolo,
32
+ type: :boolean,
33
+ default: false,
34
+ desc: [
35
+ 'Apply common Procfile.dev, config/puma.rb, and mise .env',
36
+ 'follow-up edits when safe; also generate bin/ob'
37
+ ].join(' ')
26
38
 
27
39
  FOLLOW_UP_TEMPLATE = <<~TEXT.freeze
28
40
  ============================================
@@ -46,6 +58,13 @@ module Worktrees
46
58
  chmod('bin/wt', 0o755)
47
59
  end
48
60
 
61
+ def create_browser_wrapper
62
+ return unless install_browser_wrapper?
63
+
64
+ template('bin/ob', 'bin/ob')
65
+ chmod('bin/ob', 0o755)
66
+ end
67
+
49
68
  def create_initializer
50
69
  template('rails_worktrees.rb.tt', 'config/initializers/rails_worktrees.rb')
51
70
  end
@@ -124,7 +143,7 @@ module Worktrees
124
143
  end
125
144
 
126
145
  def follow_up_notes_text
127
- [super, puma_follow_up_notes_text].join
146
+ [super, browser_follow_up_notes_text, puma_follow_up_notes_text].join
128
147
  end
129
148
 
130
149
  def installed_items_text
@@ -132,6 +151,7 @@ module Worktrees
132
151
  ' • bin/wt',
133
152
  ' • config/initializers/rails_worktrees.rb'
134
153
  ]
154
+ items << ' • bin/ob' if install_browser_wrapper?
135
155
  items << ' • Procfile.dev.worktree.example' unless options[:yolo]
136
156
  items << database_follow_up_line if database_follow_up_line
137
157
  items.join("\n")
@@ -211,6 +231,22 @@ module Worktrees
211
231
  say('Skipped mise yolo update because no supported mise config file was found.')
212
232
  end
213
233
 
234
+ def browser_follow_up_notes_text
235
+ return '' unless install_browser_wrapper?
236
+
237
+ [
238
+ '',
239
+ ' Open browser:',
240
+ ' $ bin/ob',
241
+ ' $ bin/ob contact',
242
+ " $ bin/ob --print-url '?from=nav'"
243
+ ].join("\n")
244
+ end
245
+
246
+ def install_browser_wrapper?
247
+ options[:yolo] || options[:browser]
248
+ end
249
+
214
250
  def git_repo?
215
251
  _stdout_str, _stderr_str, status = Open3.capture3(
216
252
  'git', 'rev-parse', '--is-inside-work-tree', chdir: destination_root
@@ -0,0 +1,264 @@
1
+ require 'rbconfig'
2
+ require 'uri'
3
+
4
+ module Rails
5
+ module Worktrees
6
+ # Opens the current app/worktree in a browser using the local DEV_PORT.
7
+ # rubocop:disable Metrics/ClassLength
8
+ class BrowserCommand
9
+ APP_ROOT_ENV_KEY = 'RAILS_WORKTREES_APP_ROOT'.freeze
10
+ ENV_FILE_NAME = '.env'.freeze
11
+
12
+ def initialize(argv:, io:, env:, cwd:, host_os: RbConfig::CONFIG['host_os'])
13
+ @argv = argv.dup
14
+ @stdin = io.fetch(:stdin)
15
+ @stdout = io.fetch(:stdout)
16
+ @stderr = io.fetch(:stderr)
17
+ @env = env
18
+ @cwd = cwd
19
+ @host_os = host_os
20
+ end
21
+
22
+ def run
23
+ meta_command_result = handle_meta_command
24
+ return meta_command_result unless meta_command_result.nil?
25
+ return usage_error if @argv.length > 1
26
+
27
+ url = build_url(@argv.first)
28
+ open_browser(url)
29
+ @stdout.puts("🌐 Opening #{url}")
30
+ 0
31
+ rescue Error => e
32
+ @stderr.puts("Error: #{e.message}")
33
+ 1
34
+ end
35
+
36
+ private
37
+
38
+ def handle_meta_command
39
+ case @argv.first
40
+ when '-h', '--help'
41
+ @stdout.print(usage)
42
+ 0
43
+ when '-v', '--version'
44
+ @stdout.puts("ob #{Rails::Worktrees::VERSION}")
45
+ 0
46
+ when '--url', '--print-url'
47
+ print_url_command
48
+ end
49
+ end
50
+
51
+ def usage_error
52
+ @stderr.print(usage)
53
+ 1
54
+ end
55
+
56
+ def usage
57
+ <<~USAGE
58
+ ob #{::Rails::Worktrees::VERSION}
59
+ Open the current app/worktree in your browser using DEV_PORT.
60
+
61
+ Usage: ob [route]
62
+ ob --print-url [route]
63
+
64
+ Options:
65
+ -h, --help Show this help message
66
+ -v, --version Show the script version
67
+ --url, --print-url Print the resolved URL without opening a browser
68
+
69
+ Quick start:
70
+ ob
71
+ ob contact
72
+ ob '/contact?ref=nav'
73
+ ob --print-url '?from=nav'
74
+
75
+ Route rules:
76
+ - route is optional; the default is /
77
+ - values like contact and admin/users become /contact and /admin/users
78
+ - query-only values like ?from=nav resolve to /?from=nav
79
+ - full URLs are rejected; ob only opens localhost routes
80
+ - DEV_PORT comes from .env first, then ENV['DEV_PORT'], then 3000
81
+ USAGE
82
+ end
83
+
84
+ def build_url(route)
85
+ raise Error, 'ob only accepts local routes, not full URLs' if full_url?(route)
86
+
87
+ path, query, fragment = route_components(route)
88
+ uri_options = { host: 'localhost', port: resolved_dev_port.to_i, path: path, query: query, fragment: fragment }
89
+ URI::HTTP.build(**uri_options).to_s
90
+ rescue URI::Error => e
91
+ raise Error, "Invalid route: #{e.message}"
92
+ end
93
+
94
+ def route_components(route)
95
+ raw_route = route.to_s.strip
96
+ route_without_fragment, fragment = raw_route.split('#', 2)
97
+ path_part, query = route_without_fragment.to_s.split('?', 2)
98
+
99
+ [normalized_path(path_part), presence(query), presence(fragment)]
100
+ end
101
+
102
+ def normalized_path(path_part)
103
+ cleaned = path_part.to_s
104
+ return '/' if cleaned.empty?
105
+
106
+ cleaned.start_with?('/') ? cleaned : "/#{cleaned}"
107
+ end
108
+
109
+ def resolved_dev_port
110
+ info = dev_port_resolution
111
+ note_port_fallback(info[:message])
112
+
113
+ port = info.fetch(:port)
114
+ raise Error, "DEV_PORT must be numeric, got #{port.inspect}" unless port.match?(/\A\d+\z/)
115
+ raise Error, "DEV_PORT must be between 1 and 65535, got #{port.inspect}" unless (1..65_535).cover?(port.to_i)
116
+
117
+ port.to_i
118
+ end
119
+
120
+ def print_url_command
121
+ return usage_error if @argv.length > 2
122
+
123
+ @stdout.puts(build_url(@argv[1]))
124
+ 0
125
+ end
126
+
127
+ def dev_port_from_env_file
128
+ return unless File.file?(env_path)
129
+
130
+ lines = File.readlines(env_path, chomp: true)
131
+ env_value(lines, 'DEV_PORT')
132
+ rescue StandardError => e
133
+ raise Error, "Could not read #{env_path}: #{e.message}"
134
+ end
135
+
136
+ def env_value(lines, key)
137
+ line = lines.reverse.find { |entry| entry.start_with?("#{key}=") }
138
+ value = line&.split('=', 2)&.last
139
+ value unless value&.empty?
140
+ end
141
+
142
+ def dev_port_resolution
143
+ env_file_port = dev_port_from_env_file
144
+ return { port: env_file_port, message: nil } if env_file_port
145
+
146
+ env_file_fallback_resolution
147
+ end
148
+
149
+ def env_file_fallback_resolution
150
+ env_port = presence(@env['DEV_PORT'])
151
+ return existing_env_file_resolution(env_port) if File.file?(env_path)
152
+
153
+ missing_env_file_resolution(env_port)
154
+ end
155
+
156
+ def existing_env_file_resolution(env_port)
157
+ return env_resolution_from_env_var(env_port) if env_port
158
+
159
+ {
160
+ port: '3000',
161
+ message: [
162
+ "#{env_path} does not define DEV_PORT",
163
+ "and ENV['DEV_PORT'] is unset; falling back to localhost:3000."
164
+ ].join(' ')
165
+ }
166
+ end
167
+
168
+ def missing_env_file_resolution(env_port)
169
+ return missing_env_file_resolution_from_env_var(env_port) if env_port
170
+
171
+ {
172
+ port: '3000',
173
+ message: [
174
+ "#{env_path} was not found",
175
+ "and ENV['DEV_PORT'] is unset; falling back to localhost:3000."
176
+ ].join(' ')
177
+ }
178
+ end
179
+
180
+ def env_resolution_from_env_var(env_port)
181
+ {
182
+ port: env_port,
183
+ message: "#{env_path} does not define DEV_PORT; falling back to ENV['DEV_PORT']=#{env_port}."
184
+ }
185
+ end
186
+
187
+ def missing_env_file_resolution_from_env_var(env_port)
188
+ {
189
+ port: env_port,
190
+ message: "#{env_path} was not found; falling back to ENV['DEV_PORT']=#{env_port}."
191
+ }
192
+ end
193
+
194
+ def note_port_fallback(message)
195
+ return unless message
196
+ return if @port_fallback_noted
197
+
198
+ @stderr.puts("Info: #{message}")
199
+ @port_fallback_noted = true
200
+ end
201
+
202
+ def open_browser(url)
203
+ command = opener_command
204
+ raise Error, "Could not find a browser opener for #{@host_os.inspect}" unless command
205
+ raise Error, "Failed to open browser with #{command}" unless run_opener(command, url)
206
+ end
207
+
208
+ def opener_command
209
+ opener_candidates.find { |command| command_available?(command) }
210
+ end
211
+
212
+ def run_opener(command, url)
213
+ system(command, url)
214
+ end
215
+
216
+ def command_available?(command)
217
+ @env.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |directory|
218
+ candidate = File.join(directory, command)
219
+ File.file?(candidate) && File.executable?(candidate)
220
+ end
221
+ end
222
+
223
+ def opener_candidates
224
+ return ['open'] if @host_os.match?(/darwin/i)
225
+
226
+ ['xdg-open']
227
+ end
228
+
229
+ def env_path = File.join(app_root, ENV_FILE_NAME)
230
+
231
+ def app_root
232
+ @app_root ||= begin
233
+ explicit_root = presence(@env[APP_ROOT_ENV_KEY])
234
+ explicit_root ? File.expand_path(explicit_root) : discover_app_root(File.expand_path(@cwd))
235
+ end
236
+ end
237
+
238
+ def discover_app_root(start_dir)
239
+ current = start_dir
240
+
241
+ loop do
242
+ return current if File.file?(File.join(current, 'Gemfile'))
243
+
244
+ parent = File.dirname(current)
245
+ break if parent == current
246
+
247
+ current = parent
248
+ end
249
+
250
+ start_dir
251
+ end
252
+
253
+ def full_url?(route)
254
+ route.to_s.match?(%r{\A[a-z][a-z0-9+\-.]*://}i)
255
+ end
256
+
257
+ def presence(value)
258
+ value = value.to_s
259
+ value.empty? ? nil : value
260
+ end
261
+ end
262
+ # rubocop:enable Metrics/ClassLength
263
+ end
264
+ end
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Worktrees
3
- VERSION = '0.2.2'.freeze
3
+ VERSION = '0.3.0'.freeze
4
4
  end
5
5
  end
@@ -5,6 +5,7 @@ require_relative 'worktrees/configuration'
5
5
  require_relative 'worktrees/env_bootstrapper'
6
6
  require_relative 'worktrees/command'
7
7
  require_relative 'worktrees/cli'
8
+ require_relative 'worktrees/browser_command'
8
9
  require_relative 'worktrees/database_config_updater'
9
10
  require_relative 'worktrees/procfile_updater'
10
11
  require_relative 'worktrees/mise_toml_updater'
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.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asjer Querido
@@ -34,6 +34,7 @@ description: Rails::Worktrees is a Ruby gem intended to support working with git
34
34
  email:
35
35
  - asjer@johnyontherun.com
36
36
  executables:
37
+ - ob
37
38
  - wt
38
39
  extensions: []
39
40
  extra_rdoc_files: []
@@ -43,15 +44,18 @@ files:
43
44
  - LICENSE.txt
44
45
  - README.md
45
46
  - Rakefile
47
+ - exe/ob
46
48
  - exe/wt
47
49
  - lefthook.yml
48
50
  - lib/generators/rails/worktrees/mise_follow_up.rb
49
51
  - lib/generators/rails/worktrees/puma_follow_up.rb
50
52
  - lib/generators/rails/worktrees/templates/Procfile.dev.worktree.example.tt
53
+ - lib/generators/rails/worktrees/templates/bin/ob
51
54
  - lib/generators/rails/worktrees/templates/bin/wt
52
55
  - lib/generators/rails/worktrees/templates/rails_worktrees.rb.tt
53
56
  - lib/generators/worktrees/install/install_generator.rb
54
57
  - lib/rails/worktrees.rb
58
+ - lib/rails/worktrees/browser_command.rb
55
59
  - lib/rails/worktrees/cli.rb
56
60
  - lib/rails/worktrees/command.rb
57
61
  - lib/rails/worktrees/command/environment_support.rb