rails-worktrees 0.2.2 → 0.4.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 +15 -0
- data/README.md +73 -4
- data/exe/ob +14 -0
- data/lib/generators/rails/worktrees/templates/bin/ob +6 -0
- data/lib/generators/worktrees/install/install_generator.rb +40 -4
- data/lib/rails/worktrees/browser_command.rb +264 -0
- data/lib/rails/worktrees/command/git_operations.rb +65 -4
- data/lib/rails/worktrees/command/output.rb +61 -3
- data/lib/rails/worktrees/command/workspace_paths.rb +34 -0
- data/lib/rails/worktrees/command.rb +218 -18
- data/lib/rails/worktrees/version.rb +1 -1
- data/lib/rails/worktrees.rb +1 -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: 46dd1369d93b3281d8182a90812164a1a245ff778b5af05306908989563e264e
|
|
4
|
+
data.tar.gz: 0d7e33ae49a56ea819e3a07ebfde966e1b1c18677af8c406bd15e93628ba8aff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3d9e25b8d52ec84eb983d5b5ba28679394396232f61b8e9b626cf8a8f464b49e53d9f1898d0591bcd78e66620fa2b59869b1c781ed03b7a8a5f176f973a2ebbb
|
|
7
|
+
data.tar.gz: 795403173d6b6c8c375119b7427bebc620cc73deb9402a8c1c6aa25c863b703af882b4e4b01dd9bdbd123696f1949a1e00f830a92a227e93ddff3e09a1c763ee
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{".":"0.
|
|
1
|
+
{".":"0.4.0"}
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0](https://github.com/asjer/rails-worktrees/compare/v0.3.0...v0.4.0) (2026-03-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **delete:** add `bin/wt remove` command to delete worktrees and local branches ([5905723](https://github.com/asjer/rails-worktrees/commit/5905723712e3ca11fd6614633e68855e33bb50e3))
|
|
9
|
+
|
|
10
|
+
## [0.3.0](https://github.com/asjer/rails-worktrees/compare/v0.2.2...v0.3.0) (2026-03-30)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **ob:** add `bin/ob` cli for opening `localhost:$DEV_PORT` routes in the browser ([1c94ade](https://github.com/asjer/rails-worktrees/commit/1c94adebd338cfe6e344095f3d6df089570f1e82))
|
|
16
|
+
|
|
3
17
|
## [0.2.2](https://github.com/asjer/rails-worktrees/compare/v0.2.1...v0.2.2) (2026-03-30)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -42,6 +56,7 @@
|
|
|
42
56
|
## [Unreleased]
|
|
43
57
|
|
|
44
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.
|
|
45
60
|
- Add a Rails installer generator that creates `bin/wt` and `config/initializers/rails_worktrees.rb`.
|
|
46
61
|
- Add conservative `config/database.yml` patching for common development/test database names.
|
|
47
62
|
- 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
|
|
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,15 @@ 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
|
+
bin/wt remove my-feature # remove a worktree and delete its local branch
|
|
46
|
+
bin/wt delete my-feature # alias for `bin/wt remove`
|
|
47
|
+
bin/wt remove --force my-feature # also delete an unmerged local branch
|
|
48
|
+
bin/wt prune # remove merged worktrees created by wt
|
|
49
|
+
|
|
50
|
+
bin/ob # open http://localhost:$DEV_PORT/
|
|
51
|
+
bin/ob contact # open http://localhost:$DEV_PORT/contact
|
|
52
|
+
bin/ob '/contact?from=nav' # open a local route with query params
|
|
53
|
+
bin/ob --print-url '?from=nav' # print the resolved URL without opening a browser
|
|
41
54
|
```
|
|
42
55
|
|
|
43
56
|
### Options
|
|
@@ -46,7 +59,8 @@ bin/wt --print-env my-feature # preview DEV_PORT and WORKTREE_DATABASE_SUFFIX
|
|
|
46
59
|
|------|-------------|
|
|
47
60
|
| `-h`, `--help` | Show the help message |
|
|
48
61
|
| `-v`, `--version` | Show the script version |
|
|
49
|
-
| `--dry-run [name]` | Preview
|
|
62
|
+
| `--dry-run [name]` | Preview worktree creation or cleanup without changing anything |
|
|
63
|
+
| `--force` | Force branch deletion for `bin/wt remove` / `bin/wt delete` |
|
|
50
64
|
| `--env`, `--print-env <name>` | Preview `DEV_PORT` and `WORKTREE_DATABASE_SUFFIX` |
|
|
51
65
|
|
|
52
66
|
### Default behavior
|
|
@@ -71,6 +85,33 @@ workspace/
|
|
|
71
85
|
|
|
72
86
|
`WT_WORKSPACES_ROOT` or `config.workspace_root` overrides the destination root and uses the layout `<root>/<project>/<name>`.
|
|
73
87
|
|
|
88
|
+
### Cleanup commands
|
|
89
|
+
|
|
90
|
+
`bin/wt` also supports cleanup commands for worktrees it manages:
|
|
91
|
+
|
|
92
|
+
- `bin/wt remove <name>` — remove the named worktree and delete its local branch
|
|
93
|
+
- `bin/wt delete <name>` — alias for `bin/wt remove <name>`
|
|
94
|
+
- `bin/wt prune` — remove merged worktrees created by `bin/wt` in bulk
|
|
95
|
+
|
|
96
|
+
`bin/wt remove` and `bin/wt prune` can be run from the main checkout or any sibling worktree in the same repository family.
|
|
97
|
+
|
|
98
|
+
`bin/wt remove` refuses to remove the worktree you're currently in, and by default only deletes a local branch after confirming it is already merged into `origin`'s default branch. Use `bin/wt remove --force <name>` when you intentionally want to delete an unmerged local branch too.
|
|
99
|
+
|
|
100
|
+
`bin/wt prune` only targets linked worktrees created by `bin/wt` whose local branches are already merged into `origin`'s default branch. It skips the main checkout, skips the checkout you're currently in, and asks for confirmation before deleting the batch.
|
|
101
|
+
|
|
102
|
+
If you want to preview a single removal first, use `--dry-run` with `bin/wt remove`:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
bin/wt remove --dry-run feature-auth
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
If you want to see what `bin/wt prune` would clean up before saying yes, use `--dry-run`:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
bin/wt prune --dry-run
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
|
|
74
115
|
### Interactive prompts
|
|
75
116
|
|
|
76
117
|
`bin/wt` handles several edge cases interactively:
|
|
@@ -79,6 +120,7 @@ workspace/
|
|
|
79
120
|
- **Branch already exists on origin** — asks whether to create a local tracking worktree
|
|
80
121
|
- **Target directory already exists with matching branch** — asks whether to reuse it
|
|
81
122
|
- **Target directory already exists with a different branch** — asks whether to remove and recreate it
|
|
123
|
+
- **Prune found merged worktrees created by `wt`** — asks whether to delete the batch
|
|
82
124
|
- **Retired bundled name used explicitly** — rejects it and suggests running `wt` with no argument
|
|
83
125
|
|
|
84
126
|
### Name validation
|
|
@@ -124,6 +166,33 @@ When `bin/wt` creates a worktree it writes a worktree-local `.env` with:
|
|
|
124
166
|
|
|
125
167
|
Existing `.env` values are never overwritten.
|
|
126
168
|
|
|
169
|
+
### Browser helper
|
|
170
|
+
|
|
171
|
+
If you install with `--browser` or `--yolo`, the installer generates `bin/ob`.
|
|
172
|
+
|
|
173
|
+
`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.
|
|
174
|
+
|
|
175
|
+
Route examples:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
bin/ob
|
|
179
|
+
bin/ob contact
|
|
180
|
+
bin/ob admin/users
|
|
181
|
+
bin/ob '/contact?from=footer'
|
|
182
|
+
bin/ob --print-url '?from=footer'
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`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.
|
|
186
|
+
|
|
187
|
+
If `DEV_PORT` is missing from `.env`, `bin/ob` tells you when it falls back to `ENV['DEV_PORT']` or `3000`.
|
|
188
|
+
|
|
189
|
+
Browser opening currently uses:
|
|
190
|
+
|
|
191
|
+
- `open` on macOS
|
|
192
|
+
- `xdg-open` on Linux and other Unix-like environments that provide it
|
|
193
|
+
|
|
194
|
+
For scripts and debugging, `bin/ob --print-url [route]` prints the resolved localhost URL without opening a browser.
|
|
195
|
+
|
|
127
196
|
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
197
|
|
|
129
198
|
```text
|
|
@@ -160,8 +229,8 @@ This smoke test:
|
|
|
160
229
|
- creates a temporary Rails app from a compatible Rails version
|
|
161
230
|
- installs `rails-worktrees` from the current checkout path
|
|
162
231
|
- 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
|
|
164
|
-
- creates a temporary bare `origin` and confirms `bin/wt smoke-branch`
|
|
232
|
+
- 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
|
|
233
|
+
- creates a temporary bare `origin`, confirms `bin/wt smoke-branch` creates a real worktree, and confirms `bin/wt remove smoke-branch` can remove that merged worktree from a sibling worktree checkout
|
|
165
234
|
|
|
166
235
|
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.
|
|
167
236
|
|
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
|
+
)
|
|
@@ -17,12 +17,24 @@ module Worktrees
|
|
|
17
17
|
include ::Rails::Worktrees::Generators::PumaFollowUp
|
|
18
18
|
|
|
19
19
|
namespace 'worktrees:install'
|
|
20
|
-
desc
|
|
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 :
|
|
25
|
-
|
|
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,9 +1,11 @@
|
|
|
1
1
|
require 'open3'
|
|
2
|
+
require 'fileutils'
|
|
2
3
|
|
|
3
4
|
module Rails
|
|
4
5
|
module Worktrees
|
|
5
6
|
class Command
|
|
6
7
|
# Shell-level git helpers, branch/worktree queries, and worktree creation.
|
|
8
|
+
# rubocop:disable Metrics/ModuleLength
|
|
7
9
|
module GitOperations
|
|
8
10
|
private
|
|
9
11
|
|
|
@@ -40,14 +42,18 @@ module Rails
|
|
|
40
42
|
worktree_branch_checked_out?(branch_name, worktree_list_output)
|
|
41
43
|
end
|
|
42
44
|
|
|
45
|
+
def current_checkout_branch
|
|
46
|
+
git_capture('branch', '--show-current', allow_failure: true).strip
|
|
47
|
+
end
|
|
48
|
+
|
|
43
49
|
def registered_worktree_path?(target_dir)
|
|
44
50
|
normalized_target = canonical_path(target_dir)
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
worktree_entries.any? { |entry| entry[:path] == normalized_target }
|
|
53
|
+
end
|
|
48
54
|
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
def worktree_entries_for_branch(branch_name)
|
|
56
|
+
worktree_entries.select { |entry| entry[:branch_name] == branch_name }
|
|
51
57
|
end
|
|
52
58
|
|
|
53
59
|
def worktree_branch_checked_out?(branch_name, output)
|
|
@@ -59,11 +65,38 @@ module Rails
|
|
|
59
65
|
|
|
60
66
|
def worktree_list_output = git_capture('worktree', 'list', '--porcelain')
|
|
61
67
|
|
|
68
|
+
def worktree_entries
|
|
69
|
+
worktree_list_output.split("\n\n").filter_map do |block|
|
|
70
|
+
entry = parse_worktree_entry(block)
|
|
71
|
+
entry[:path] ? entry : nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_worktree_entry(block)
|
|
76
|
+
entry = { branch_name: nil }
|
|
77
|
+
|
|
78
|
+
block.each_line(chomp: true) do |line|
|
|
79
|
+
case line
|
|
80
|
+
when /\Aworktree /
|
|
81
|
+
entry[:path] = canonical_path(line.delete_prefix('worktree '))
|
|
82
|
+
when %r{\Abranch refs/heads/}
|
|
83
|
+
entry[:branch_name] = line.delete_prefix('branch refs/heads/')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
entry
|
|
88
|
+
end
|
|
89
|
+
|
|
62
90
|
def canonical_path(path)
|
|
63
91
|
File.realpath(path)
|
|
64
92
|
rescue Errno::ENOENT then File.expand_path(path)
|
|
65
93
|
end
|
|
66
94
|
|
|
95
|
+
def branch_merged_into_default?(branch_name)
|
|
96
|
+
default_branch = resolve_default_branch
|
|
97
|
+
git_success?('merge-base', '--is-ancestor', branch_name, "origin/#{default_branch}")
|
|
98
|
+
end
|
|
99
|
+
|
|
67
100
|
def create_new_branch_worktree(branch_name, target_dir)
|
|
68
101
|
default_branch = resolve_default_branch
|
|
69
102
|
if dry_run?
|
|
@@ -101,6 +134,33 @@ module Rails
|
|
|
101
134
|
git!('worktree', 'remove', '--force', target_dir)
|
|
102
135
|
end
|
|
103
136
|
|
|
137
|
+
def remove_target_path(target_dir)
|
|
138
|
+
if registered_worktree_path?(target_dir)
|
|
139
|
+
remove_registered_worktree(target_dir)
|
|
140
|
+
else
|
|
141
|
+
info("Removing existing target path '#{target_dir}'")
|
|
142
|
+
FileUtils.rm_rf(target_dir)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def delete_local_branch(branch_name, force: false)
|
|
147
|
+
ensure_local_branch_removable!(branch_name, force: force)
|
|
148
|
+
|
|
149
|
+
info("Deleting local branch '#{branch_name}'")
|
|
150
|
+
git!('branch', force ? '-D' : '-d', branch_name)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def ensure_local_branch_removable!(branch_name, force: false)
|
|
154
|
+
return if force
|
|
155
|
+
|
|
156
|
+
default_branch = resolve_default_branch
|
|
157
|
+
return if branch_merged_into_default?(branch_name)
|
|
158
|
+
|
|
159
|
+
raise Error,
|
|
160
|
+
"Local branch '#{branch_name}' is not merged into origin/#{default_branch}. " \
|
|
161
|
+
'Re-run with --force to delete it.'
|
|
162
|
+
end
|
|
163
|
+
|
|
104
164
|
def git!(*)
|
|
105
165
|
stdout_str, stderr_str, status = Open3.capture3(@env.to_h, 'git', *, chdir: @cwd)
|
|
106
166
|
return stdout_str if status.success?
|
|
@@ -137,6 +197,7 @@ module Rails
|
|
|
137
197
|
git!('worktree', 'add', '--force', target_dir, branch_name)
|
|
138
198
|
end
|
|
139
199
|
end
|
|
200
|
+
# rubocop:enable Metrics/ModuleLength
|
|
140
201
|
end
|
|
141
202
|
end
|
|
142
203
|
end
|
|
@@ -2,6 +2,7 @@ module Rails
|
|
|
2
2
|
module Worktrees
|
|
3
3
|
class Command
|
|
4
4
|
# User-facing output, prompts, and help text.
|
|
5
|
+
# rubocop:disable Metrics/ModuleLength
|
|
5
6
|
module Output
|
|
6
7
|
private
|
|
7
8
|
|
|
@@ -26,16 +27,20 @@ module Rails
|
|
|
26
27
|
def usage
|
|
27
28
|
<<~USAGE
|
|
28
29
|
wt #{::Rails::Worktrees::VERSION}
|
|
29
|
-
Create Git worktrees for the current repository.
|
|
30
|
+
Create and clean up Git worktrees for the current repository.
|
|
30
31
|
|
|
31
32
|
Usage: wt [worktree-name]
|
|
32
33
|
wt --dry-run [worktree-name]
|
|
33
34
|
wt --print-env <worktree-name>
|
|
35
|
+
wt remove [--dry-run] [--force] <worktree-name>
|
|
36
|
+
wt delete [--dry-run] [--force] <worktree-name>
|
|
37
|
+
wt prune [--dry-run]
|
|
34
38
|
|
|
35
39
|
Options:
|
|
36
40
|
-h, --help Show this help message
|
|
37
41
|
-v, --version Show the script version
|
|
38
|
-
--dry-run [name] Preview
|
|
42
|
+
--dry-run [name] Preview worktree creation or cleanup without changing anything
|
|
43
|
+
--force Delete an unmerged local branch with wt remove/delete
|
|
39
44
|
--env, --print-env <name> Preview DEV_PORT and WORKTREE_DATABASE_SUFFIX
|
|
40
45
|
|
|
41
46
|
Quick start:
|
|
@@ -43,12 +48,17 @@ module Rails
|
|
|
43
48
|
wt my-feature Use an explicit worktree name
|
|
44
49
|
wt --dry-run my-feature
|
|
45
50
|
wt --print-env my-feature
|
|
51
|
+
wt remove my-feature
|
|
52
|
+
wt remove --force my-feature
|
|
53
|
+
wt prune
|
|
46
54
|
|
|
47
55
|
How it works:
|
|
48
56
|
- by default creates worktrees beside the repo in ../<project>.worktrees/<name>
|
|
49
57
|
- when workspace_root or WT_WORKSPACES_ROOT is set, creates worktrees in <root>/<project>/<name>
|
|
50
58
|
- always uses the branch name #{@configuration.branch_prefix}/<name>
|
|
51
59
|
- bases new branches on the repository's origin default branch
|
|
60
|
+
- wt remove/delete can run from the main checkout or any sibling worktree, but never remove the worktree you're currently in
|
|
61
|
+
- wt prune removes merged worktrees created by wt while skipping the main checkout and the checkout you're in
|
|
52
62
|
- auto-discovers bundled *.txt files from #{@configuration.name_sources_path}
|
|
53
63
|
- retires bundled names in #{@configuration.used_names_file}
|
|
54
64
|
USAGE
|
|
@@ -75,7 +85,7 @@ module Rails
|
|
|
75
85
|
@stdout.puts("→ #{message}")
|
|
76
86
|
end
|
|
77
87
|
|
|
78
|
-
def announce_dry_run = info('Dry run: previewing worktree
|
|
88
|
+
def announce_dry_run = info('Dry run: previewing worktree changes without applying them')
|
|
79
89
|
|
|
80
90
|
def complete_dry_run(context, env_values:)
|
|
81
91
|
success('Dry run complete')
|
|
@@ -90,6 +100,53 @@ module Rails
|
|
|
90
100
|
complete_dry_run(context, env_values: preview_result&.values)
|
|
91
101
|
end
|
|
92
102
|
|
|
103
|
+
def complete_remove_dry_run(context, worktree_exists:, branch_exists:)
|
|
104
|
+
info(remove_dry_run_target_message(context, worktree_exists: worktree_exists))
|
|
105
|
+
info(remove_dry_run_branch_message(context, branch_exists: branch_exists))
|
|
106
|
+
|
|
107
|
+
success('Dry run complete')
|
|
108
|
+
print_context_summary(context, env_values: nil)
|
|
109
|
+
info('No changes were made.')
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def remove_dry_run_target_message(context, worktree_exists:)
|
|
114
|
+
return "Would skip worktree removal because '#{context[:target_dir]}' does not exist" unless worktree_exists
|
|
115
|
+
|
|
116
|
+
action = if registered_worktree_path?(context[:target_dir])
|
|
117
|
+
'remove registered worktree at'
|
|
118
|
+
else
|
|
119
|
+
'remove existing target path'
|
|
120
|
+
end
|
|
121
|
+
"Would #{action} '#{context[:target_dir]}'"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def remove_dry_run_branch_message(context, branch_exists:)
|
|
125
|
+
unless branch_exists
|
|
126
|
+
return "Would skip local branch deletion because '#{context[:branch_name]}' does not exist"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
"Would delete local branch '#{context[:branch_name]}'"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def print_prune_candidates(candidates)
|
|
133
|
+
candidates.each do |context|
|
|
134
|
+
info("#{context[:worktree_name]} => #{context[:target_dir]} (#{context[:branch_name]})")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def complete_prune_noop
|
|
139
|
+
info('No merged worktrees created by wt are ready to prune.')
|
|
140
|
+
0
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def complete_prune_dry_run(candidates)
|
|
144
|
+
info("Would prune #{candidates.length} merged worktree#{'s' unless candidates.length == 1}")
|
|
145
|
+
success('Dry run complete')
|
|
146
|
+
info('No changes were made.')
|
|
147
|
+
0
|
|
148
|
+
end
|
|
149
|
+
|
|
93
150
|
def warning(message)
|
|
94
151
|
@stderr.puts("⚠️ #{message}")
|
|
95
152
|
end
|
|
@@ -124,6 +181,7 @@ module Rails
|
|
|
124
181
|
@stdout.puts("Suffix: #{suffix}")
|
|
125
182
|
end
|
|
126
183
|
end
|
|
184
|
+
# rubocop:enable Metrics/ModuleLength
|
|
127
185
|
end
|
|
128
186
|
end
|
|
129
187
|
end
|
|
@@ -5,6 +5,28 @@ module Rails
|
|
|
5
5
|
module WorkspacePaths
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
+
def resolve_repository_context
|
|
9
|
+
current_root = canonical_path(git_capture('rev-parse', '--show-toplevel').strip)
|
|
10
|
+
common_dir = expand_git_path(git_capture('rev-parse', '--git-common-dir').strip)
|
|
11
|
+
primary_root = primary_checkout_root_for(current_root, common_dir)
|
|
12
|
+
|
|
13
|
+
repository_context_for(current_root, primary_root)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def repository_context_for(current_root, primary_root)
|
|
17
|
+
project_name = File.basename(primary_root)
|
|
18
|
+
workspaces = resolve_workspaces(primary_root, project_name)
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
current_root: current_root,
|
|
22
|
+
primary_root: primary_root,
|
|
23
|
+
project_name: project_name,
|
|
24
|
+
workspaces: workspaces,
|
|
25
|
+
workspaces_root: workspaces[:root],
|
|
26
|
+
uses_default_workspace_root: workspaces[:uses_default_root]
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
8
30
|
def resolve_workspaces(repo_root, project_name)
|
|
9
31
|
explicit_root = configured_workspaces_root
|
|
10
32
|
return { root: explicit_root, uses_default_root: false } if explicit_root
|
|
@@ -38,6 +60,18 @@ module Rails
|
|
|
38
60
|
FileUtils.mkdir_p(parent_dir)
|
|
39
61
|
end
|
|
40
62
|
|
|
63
|
+
def primary_checkout_root_for(current_root, common_dir)
|
|
64
|
+
return current_root unless File.basename(common_dir) == '.git'
|
|
65
|
+
|
|
66
|
+
canonical_path(File.dirname(common_dir))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def expand_git_path(path)
|
|
70
|
+
return path if path.start_with?('/')
|
|
71
|
+
|
|
72
|
+
File.expand_path(path, @cwd)
|
|
73
|
+
end
|
|
74
|
+
|
|
41
75
|
def present_path?(path)
|
|
42
76
|
!path.nil? && !path.empty?
|
|
43
77
|
end
|
|
@@ -10,6 +10,8 @@ module Rails
|
|
|
10
10
|
# Creates or attaches worktrees for the current repository.
|
|
11
11
|
# rubocop:disable Metrics/ClassLength
|
|
12
12
|
class Command
|
|
13
|
+
REMOVE_SUBCOMMANDS = %w[remove delete].freeze
|
|
14
|
+
|
|
13
15
|
include GitOperations
|
|
14
16
|
include EnvironmentSupport
|
|
15
17
|
include NamePicking
|
|
@@ -24,18 +26,14 @@ module Rails
|
|
|
24
26
|
@env = env
|
|
25
27
|
@cwd = cwd
|
|
26
28
|
@configuration = configuration
|
|
27
|
-
|
|
28
|
-
@argv.shift if dry_run?
|
|
29
|
+
extract_flags!
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def run
|
|
32
|
-
return usage_error if dry_run? && @argv.first&.start_with?('-')
|
|
33
|
-
|
|
34
33
|
meta_command_result = handle_meta_command
|
|
35
34
|
return meta_command_result unless meta_command_result.nil?
|
|
36
|
-
return usage_error if @argv.length > 1
|
|
37
35
|
|
|
38
|
-
|
|
36
|
+
execute_requested_command
|
|
39
37
|
rescue Error => e
|
|
40
38
|
@stderr.puts("Error: #{e.message}")
|
|
41
39
|
1
|
|
@@ -45,6 +43,40 @@ module Rails
|
|
|
45
43
|
|
|
46
44
|
def dry_run? = @dry_run
|
|
47
45
|
|
|
46
|
+
def force? = @force
|
|
47
|
+
|
|
48
|
+
def extract_flags!
|
|
49
|
+
@dry_run = extract_flag!('--dry-run')
|
|
50
|
+
@force = extract_flag!('--force')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extract_flag!(flag)
|
|
54
|
+
extracted = false
|
|
55
|
+
@argv.reject! do |arg|
|
|
56
|
+
next false unless arg == flag
|
|
57
|
+
|
|
58
|
+
extracted = true
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
extracted
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def remove_subcommand?
|
|
65
|
+
REMOVE_SUBCOMMANDS.include?(@argv.first)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prune_subcommand?
|
|
69
|
+
@argv.first == 'prune'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def execute_requested_command
|
|
73
|
+
return execute_remove_command if remove_subcommand?
|
|
74
|
+
return execute_prune_command if prune_subcommand?
|
|
75
|
+
return usage_error if @argv.length > 1 || force?
|
|
76
|
+
|
|
77
|
+
execute_worktree_command
|
|
78
|
+
end
|
|
79
|
+
|
|
48
80
|
def execute_worktree_command
|
|
49
81
|
require_git_repo
|
|
50
82
|
announce_dry_run if dry_run?
|
|
@@ -61,16 +93,95 @@ module Rails
|
|
|
61
93
|
finish(context)
|
|
62
94
|
end
|
|
63
95
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
96
|
+
def execute_remove_command
|
|
97
|
+
require_git_repo
|
|
98
|
+
announce_dry_run if dry_run?
|
|
99
|
+
validate_remove_args!
|
|
100
|
+
|
|
101
|
+
context = resolve_worktree_context(explicit_worktree_name: @argv.fetch(1))
|
|
102
|
+
removal_status = removal_status_for(context)
|
|
103
|
+
|
|
104
|
+
ensure_removable!(context, **removal_status)
|
|
105
|
+
return complete_remove_dry_run(context, **removal_status) if dry_run?
|
|
106
|
+
|
|
107
|
+
perform_remove(context, **removal_status)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_remove_args!
|
|
111
|
+
raise Error, "Usage: wt #{@argv.first} [--dry-run] [--force] <worktree-name>" unless @argv.length == 2
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def removal_status_for(context)
|
|
115
|
+
{
|
|
116
|
+
worktree_exists: File.exist?(context[:target_dir]),
|
|
117
|
+
branch_exists: branch_exists_locally?(context[:branch_name])
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def perform_remove(context, worktree_exists:, branch_exists:)
|
|
122
|
+
remove_target_path(context[:target_dir]) if worktree_exists
|
|
123
|
+
delete_local_branch(context[:branch_name], force: force?) if branch_exists
|
|
124
|
+
|
|
125
|
+
success("Removed '#{context[:worktree_name]}'")
|
|
126
|
+
print_context_summary(context, env_values: nil)
|
|
127
|
+
0
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def execute_prune_command
|
|
131
|
+
require_git_repo
|
|
132
|
+
announce_dry_run if dry_run?
|
|
133
|
+
validate_prune_args!
|
|
134
|
+
|
|
135
|
+
candidates = prune_candidates
|
|
136
|
+
return complete_prune_noop if candidates.empty?
|
|
137
|
+
|
|
138
|
+
prepare_prune(candidates)
|
|
139
|
+
return complete_prune_dry_run(candidates) if dry_run?
|
|
140
|
+
|
|
141
|
+
perform_prune(candidates)
|
|
142
|
+
|
|
143
|
+
success("Pruned #{candidates.length} worktree#{'s' unless candidates.length == 1}")
|
|
144
|
+
0
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def validate_prune_args!
|
|
148
|
+
raise Error, 'Usage: wt prune' unless @argv.length == 1
|
|
149
|
+
raise Error, 'The --force flag is only supported with wt remove.' if force?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def announce_prune_candidates(candidates)
|
|
153
|
+
info("Found #{candidates.length} merged worktree#{'s' unless candidates.length == 1} created by wt:")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def prepare_prune(candidates)
|
|
157
|
+
announce_prune_candidates(candidates)
|
|
158
|
+
print_prune_candidates(candidates)
|
|
159
|
+
confirm_or_abort!(prune_confirmation_prompt(candidates.length))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def prune_confirmation_prompt(count)
|
|
163
|
+
branches = count == 1 ? 'its local branch' : 'their local branches'
|
|
164
|
+
"Delete #{count} merged worktree#{'s' unless count == 1} and #{branches}?"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def perform_prune(candidates)
|
|
168
|
+
candidates.each do |context|
|
|
169
|
+
remove_target_path(context[:target_dir])
|
|
170
|
+
delete_local_branch(context[:branch_name], force: false)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def resolve_worktree_context(explicit_worktree_name: nil, repository: nil)
|
|
175
|
+
repository ||= resolve_repository_context
|
|
176
|
+
project_name = repository[:project_name]
|
|
177
|
+
workspaces = repository[:workspaces]
|
|
68
178
|
worktree_name = resolved_worktree_name(project_name, workspaces, explicit_worktree_name)
|
|
69
179
|
|
|
70
|
-
|
|
71
|
-
|
|
180
|
+
repository.merge(
|
|
181
|
+
worktree_name: worktree_name,
|
|
72
182
|
branch_name: branch_name_for(worktree_name),
|
|
73
|
-
target_dir: target_dir_for(project_name, worktree_name, workspaces)
|
|
183
|
+
target_dir: target_dir_for(project_name, worktree_name, workspaces)
|
|
184
|
+
)
|
|
74
185
|
end
|
|
75
186
|
|
|
76
187
|
def resolved_worktree_name(project_name, workspaces, explicit_worktree_name)
|
|
@@ -133,16 +244,105 @@ module Rails
|
|
|
133
244
|
confirm_or_abort!("Target path '#{target_dir}' already exists. Remove it and recreate the worktree?")
|
|
134
245
|
return info("Would remove existing target path '#{target_dir}'") if dry_run?
|
|
135
246
|
|
|
136
|
-
|
|
137
|
-
remove_registered_worktree(target_dir)
|
|
138
|
-
else
|
|
139
|
-
FileUtils.rm_rf(target_dir)
|
|
140
|
-
end
|
|
247
|
+
remove_target_path(target_dir)
|
|
141
248
|
end
|
|
142
249
|
|
|
143
250
|
def branch_name_for(worktree_name)
|
|
144
251
|
"#{@configuration.branch_prefix}/#{worktree_name}"
|
|
145
252
|
end
|
|
253
|
+
|
|
254
|
+
def ensure_removable!(context, worktree_exists:, branch_exists:)
|
|
255
|
+
ensure_remove_target_exists!(context, worktree_exists: worktree_exists, branch_exists: branch_exists)
|
|
256
|
+
ensure_not_removing_protected_checkout!(context)
|
|
257
|
+
ensure_branch_not_checked_out_here!(context)
|
|
258
|
+
ensure_branch_not_checked_out_elsewhere!(context)
|
|
259
|
+
ensure_local_branch_removable!(context[:branch_name], force: force?) if branch_exists
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def ensure_remove_target_exists!(context, worktree_exists:, branch_exists:)
|
|
263
|
+
return if worktree_exists || branch_exists
|
|
264
|
+
|
|
265
|
+
raise Error, "No worktree or local branch found for '#{context[:worktree_name]}'."
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def ensure_not_removing_protected_checkout!(context)
|
|
269
|
+
target_path = canonical_path(context[:target_dir])
|
|
270
|
+
raise Error, 'Cannot remove the main checkout.' if target_path == canonical_path(context[:primary_root])
|
|
271
|
+
|
|
272
|
+
return unless target_path == canonical_path(context[:current_root])
|
|
273
|
+
|
|
274
|
+
raise Error,
|
|
275
|
+
'Cannot remove the current worktree from inside itself. ' \
|
|
276
|
+
'Run this command from the main checkout or another worktree.'
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def ensure_branch_not_checked_out_here!(context)
|
|
280
|
+
return unless current_checkout_branch == context[:branch_name]
|
|
281
|
+
|
|
282
|
+
raise Error,
|
|
283
|
+
"Branch '#{context[:branch_name]}' is checked out in the current worktree. " \
|
|
284
|
+
'Run this command from the main checkout or another worktree.'
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def ensure_branch_not_checked_out_elsewhere!(context)
|
|
288
|
+
target_path = canonical_path(context[:target_dir])
|
|
289
|
+
unexpected_paths = worktree_entries_for_branch(context[:branch_name])
|
|
290
|
+
.map { |entry| entry[:path] }
|
|
291
|
+
.reject { |path| path == target_path }
|
|
292
|
+
return if unexpected_paths.empty?
|
|
293
|
+
|
|
294
|
+
raise Error,
|
|
295
|
+
"Branch '#{context[:branch_name]}' is checked out in another worktree at '#{unexpected_paths.first}'."
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def prune_candidates
|
|
299
|
+
repository = resolve_repository_context
|
|
300
|
+
|
|
301
|
+
worktree_entries.filter_map do |entry|
|
|
302
|
+
prune_candidate_for(entry, repository)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def prune_candidate_for(entry, repository)
|
|
307
|
+
branch_name = entry[:branch_name]
|
|
308
|
+
return unless prunable_worktree_entry?(entry, branch_name, repository)
|
|
309
|
+
|
|
310
|
+
context = resolve_worktree_context(
|
|
311
|
+
explicit_worktree_name: worktree_name_for_branch(branch_name),
|
|
312
|
+
repository: repository
|
|
313
|
+
)
|
|
314
|
+
return unless prune_target_matches_entry?(entry, context)
|
|
315
|
+
|
|
316
|
+
context
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def prunable_worktree_entry?(entry, branch_name, repository)
|
|
320
|
+
wt_managed_branch?(branch_name) &&
|
|
321
|
+
!protected_prune_path?(entry[:path], repository) &&
|
|
322
|
+
!branch_checked_out_elsewhere_for_prune?(branch_name, entry[:path]) &&
|
|
323
|
+
branch_exists_locally?(branch_name) &&
|
|
324
|
+
branch_merged_into_default?(branch_name)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def branch_checked_out_elsewhere_for_prune?(branch_name, target_path)
|
|
328
|
+
worktree_entries_for_branch(branch_name).any? { |other| other[:path] != target_path }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def prune_target_matches_entry?(entry, context)
|
|
332
|
+
entry[:path] == canonical_path(context[:target_dir])
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def protected_prune_path?(path, repository)
|
|
336
|
+
path == repository[:primary_root] || path == repository[:current_root]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def wt_managed_branch?(branch_name)
|
|
340
|
+
branch_name&.start_with?("#{@configuration.branch_prefix}/")
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def worktree_name_for_branch(branch_name)
|
|
344
|
+
branch_name.delete_prefix("#{@configuration.branch_prefix}/")
|
|
345
|
+
end
|
|
146
346
|
end
|
|
147
347
|
# rubocop:enable Metrics/ClassLength
|
|
148
348
|
end
|
data/lib/rails/worktrees.rb
CHANGED
|
@@ -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.
|
|
4
|
+
version: 0.4.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
|