rails-worktrees 0.3.0 → 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 +7 -0
- data/README.md +35 -2
- 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
- metadata +1 -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,12 @@
|
|
|
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
|
+
|
|
3
10
|
## [0.3.0](https://github.com/asjer/rails-worktrees/compare/v0.2.2...v0.3.0) (2026-03-30)
|
|
4
11
|
|
|
5
12
|
|
data/README.md
CHANGED
|
@@ -42,6 +42,10 @@ bin/wt # auto-pick a name from bundled *.txt lists
|
|
|
42
42
|
bin/wt my-feature # use an explicit worktree name
|
|
43
43
|
bin/wt --dry-run my-feature # preview the full setup without changing anything
|
|
44
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
|
|
45
49
|
|
|
46
50
|
bin/ob # open http://localhost:$DEV_PORT/
|
|
47
51
|
bin/ob contact # open http://localhost:$DEV_PORT/contact
|
|
@@ -55,7 +59,8 @@ bin/ob --print-url '?from=nav' # print the resolved URL without opening a brows
|
|
|
55
59
|
|------|-------------|
|
|
56
60
|
| `-h`, `--help` | Show the help message |
|
|
57
61
|
| `-v`, `--version` | Show the script version |
|
|
58
|
-
| `--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` |
|
|
59
64
|
| `--env`, `--print-env <name>` | Preview `DEV_PORT` and `WORKTREE_DATABASE_SUFFIX` |
|
|
60
65
|
|
|
61
66
|
### Default behavior
|
|
@@ -80,6 +85,33 @@ workspace/
|
|
|
80
85
|
|
|
81
86
|
`WT_WORKSPACES_ROOT` or `config.workspace_root` overrides the destination root and uses the layout `<root>/<project>/<name>`.
|
|
82
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
|
+
|
|
83
115
|
### Interactive prompts
|
|
84
116
|
|
|
85
117
|
`bin/wt` handles several edge cases interactively:
|
|
@@ -88,6 +120,7 @@ workspace/
|
|
|
88
120
|
- **Branch already exists on origin** — asks whether to create a local tracking worktree
|
|
89
121
|
- **Target directory already exists with matching branch** — asks whether to reuse it
|
|
90
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
|
|
91
124
|
- **Retired bundled name used explicitly** — rejects it and suggests running `wt` with no argument
|
|
92
125
|
|
|
93
126
|
### Name validation
|
|
@@ -197,7 +230,7 @@ This smoke test:
|
|
|
197
230
|
- installs `rails-worktrees` from the current checkout path
|
|
198
231
|
- runs `bin/rails generate worktrees:install --yolo`
|
|
199
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
|
|
200
|
-
- creates a temporary bare `origin` and confirms `bin/wt smoke-branch`
|
|
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
|
|
201
234
|
|
|
202
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.
|
|
203
236
|
|
|
@@ -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
|