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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46ec0bdd9361eabe4925e37760bcd5d050c8f71072ec93cfdb427e244186ea8e
4
- data.tar.gz: 965a2ea5c6d454739f833cf0a534a4b195903531d159e2fb25f5eff9bb71083c
3
+ metadata.gz: 46dd1369d93b3281d8182a90812164a1a245ff778b5af05306908989563e264e
4
+ data.tar.gz: 0d7e33ae49a56ea819e3a07ebfde966e1b1c18677af8c406bd15e93628ba8aff
5
5
  SHA512:
6
- metadata.gz: fa02fa0f6786fb3865326f8cef1e4beb804a25411cc7b5d15676236424b23a5cd4e5b55bd21550f1b1e3f422384f1c06662ff05b9d40e291e931b1bd09775af2
7
- data.tar.gz: 43269ef67990a1a4986f407a96696b9b01faabf8a64930e676c3c505a214d38b228ba3dae5e9ffa76fc74dc59d96bbdad3eafceb42c99236c64a23954fbefced
6
+ metadata.gz: 3d9e25b8d52ec84eb983d5b5ba28679394396232f61b8e9b626cf8a8f464b49e53d9f1898d0591bcd78e66620fa2b59869b1c781ed03b7a8a5f176f973a2ebbb
7
+ data.tar.gz: 795403173d6b6c8c375119b7427bebc620cc73deb9402a8c1c6aa25c863b703af882b4e4b01dd9bdbd123696f1949a1e00f830a92a227e93ddff3e09a1c763ee
@@ -1 +1 @@
1
- {".":"0.3.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 the full worktree setup without changing anything |
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` creates a real worktree
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
- worktree_list_output.each_line.any? do |line|
47
- next unless line.start_with?('worktree ')
52
+ worktree_entries.any? { |entry| entry[:path] == normalized_target }
53
+ end
48
54
 
49
- canonical_path(line.delete_prefix('worktree ').strip) == normalized_target
50
- end
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 the full worktree setup without changing anything
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 setup without making changes')
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
- @dry_run = @argv.first == '--dry-run'
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
- execute_worktree_command
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 resolve_worktree_context(explicit_worktree_name: nil)
65
- repo_root = git_capture('rev-parse', '--show-toplevel').strip
66
- project_name = File.basename(repo_root)
67
- workspaces = resolve_workspaces(repo_root, project_name)
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
- { project_name: project_name, workspaces_root: workspaces[:root], worktree_name: worktree_name,
71
- uses_default_workspace_root: workspaces[:uses_default_root],
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
- if registered_worktree_path?(target_dir)
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
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Worktrees
3
- VERSION = '0.3.0'.freeze
3
+ VERSION = '0.4.0'.freeze
4
4
  end
5
5
  end
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asjer Querido