ace-git-worktree 0.19.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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/git/worktree.yml +250 -0
  3. data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +19 -0
  4. data/CHANGELOG.md +957 -0
  5. data/LICENSE +21 -0
  6. data/README.md +40 -0
  7. data/Rakefile +14 -0
  8. data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
  9. data/docs/demo/ace-git-worktree-getting-started.tape.yml +28 -0
  10. data/docs/demo/fixtures/README.md +3 -0
  11. data/docs/demo/fixtures/sample.txt +1 -0
  12. data/docs/getting-started.md +114 -0
  13. data/docs/handbook.md +38 -0
  14. data/docs/usage.md +334 -0
  15. data/exe/ace-git-worktree +24 -0
  16. data/handbook/agents/worktree.ag.md +189 -0
  17. data/handbook/skills/as-git-worktree/SKILL.md +27 -0
  18. data/handbook/skills/as-git-worktree-create/SKILL.md +21 -0
  19. data/handbook/skills/as-git-worktree-manage/SKILL.md +20 -0
  20. data/handbook/workflow-instructions/git/worktree-create.wf.md +262 -0
  21. data/handbook/workflow-instructions/git/worktree-manage.wf.md +384 -0
  22. data/handbook/workflow-instructions/git/worktree.wf.md +224 -0
  23. data/lib/ace/git/worktree/atoms/git_command.rb +121 -0
  24. data/lib/ace/git/worktree/atoms/path_expander.rb +189 -0
  25. data/lib/ace/git/worktree/atoms/slug_generator.rb +235 -0
  26. data/lib/ace/git/worktree/atoms/task_id_extractor.rb +91 -0
  27. data/lib/ace/git/worktree/cli/commands/config.rb +50 -0
  28. data/lib/ace/git/worktree/cli/commands/create.rb +80 -0
  29. data/lib/ace/git/worktree/cli/commands/list.rb +76 -0
  30. data/lib/ace/git/worktree/cli/commands/prune.rb +43 -0
  31. data/lib/ace/git/worktree/cli/commands/remove.rb +48 -0
  32. data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +66 -0
  33. data/lib/ace/git/worktree/cli/commands/switch.rb +44 -0
  34. data/lib/ace/git/worktree/cli.rb +103 -0
  35. data/lib/ace/git/worktree/commands/config_command.rb +351 -0
  36. data/lib/ace/git/worktree/commands/create_command.rb +961 -0
  37. data/lib/ace/git/worktree/commands/list_command.rb +247 -0
  38. data/lib/ace/git/worktree/commands/prune_command.rb +260 -0
  39. data/lib/ace/git/worktree/commands/remove_command.rb +522 -0
  40. data/lib/ace/git/worktree/commands/switch_command.rb +249 -0
  41. data/lib/ace/git/worktree/configuration.rb +167 -0
  42. data/lib/ace/git/worktree/models/worktree_config.rb +502 -0
  43. data/lib/ace/git/worktree/models/worktree_info.rb +303 -0
  44. data/lib/ace/git/worktree/models/worktree_metadata.rb +294 -0
  45. data/lib/ace/git/worktree/molecules/config_loader.rb +125 -0
  46. data/lib/ace/git/worktree/molecules/current_task_linker.rb +136 -0
  47. data/lib/ace/git/worktree/molecules/hook_executor.rb +361 -0
  48. data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +186 -0
  49. data/lib/ace/git/worktree/molecules/pr_creator.rb +253 -0
  50. data/lib/ace/git/worktree/molecules/task_committer.rb +329 -0
  51. data/lib/ace/git/worktree/molecules/task_fetcher.rb +244 -0
  52. data/lib/ace/git/worktree/molecules/task_pusher.rb +183 -0
  53. data/lib/ace/git/worktree/molecules/task_status_updater.rb +447 -0
  54. data/lib/ace/git/worktree/molecules/worktree_creator.rb +832 -0
  55. data/lib/ace/git/worktree/molecules/worktree_lister.rb +337 -0
  56. data/lib/ace/git/worktree/molecules/worktree_remover.rb +416 -0
  57. data/lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb +906 -0
  58. data/lib/ace/git/worktree/organisms/worktree_manager.rb +714 -0
  59. data/lib/ace/git/worktree/version.rb +9 -0
  60. data/lib/ace/git/worktree.rb +215 -0
  61. metadata +218 -0
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Worktree
6
+ module Molecules
7
+ # Worktree remover molecule
8
+ #
9
+ # Removes git worktrees with proper cleanup, validation, and error handling.
10
+ # Provides options for force removal and handles various edge cases.
11
+ #
12
+ # @example Remove a worktree
13
+ # remover = WorktreeRemover.new
14
+ # success = remover.remove("/path/to/worktree")
15
+ #
16
+ # @example Force remove with changes
17
+ # success = remover.remove("/path/to/worktree", force: true)
18
+ class WorktreeRemover
19
+ # Fallback timeout for git commands
20
+ # Used only when config is unavailable
21
+ FALLBACK_TIMEOUT = 30
22
+
23
+ # Initialize a new WorktreeRemover
24
+ #
25
+ # @param timeout [Integer, nil] Command timeout in seconds (uses config default if nil)
26
+ def initialize(timeout: nil)
27
+ @timeout = timeout || config_timeout
28
+ end
29
+
30
+ private
31
+
32
+ # Get timeout from config or fallback
33
+ # @return [Integer] Timeout in seconds
34
+ def config_timeout
35
+ Ace::Git::Worktree.remove_timeout
36
+ rescue
37
+ FALLBACK_TIMEOUT
38
+ end
39
+
40
+ public
41
+
42
+ # Remove a worktree by path
43
+ #
44
+ # @param worktree_path [String] Path to the worktree directory
45
+ # @param force [Boolean] Force removal even if there are uncommitted changes
46
+ # @param remove_directory [Boolean] Also remove the worktree directory
47
+ # @param delete_branch [Boolean] Also delete the associated branch
48
+ # @param ignore_untracked [Boolean] Treat untracked files as clean for removal checks
49
+ # @return [Hash] Result with :success, :message, :error
50
+ #
51
+ # @example
52
+ # remover = WorktreeRemover.new
53
+ # result = remover.remove("/project/.ace-wt/task.081")
54
+ # # => { success: true, message: "Worktree removed successfully", error: nil }
55
+ def remove(
56
+ worktree_path,
57
+ force: false,
58
+ remove_directory: true,
59
+ delete_branch: false,
60
+ ignore_untracked: false
61
+ )
62
+ return error_result("Worktree path is required") if worktree_path.nil? || worktree_path.empty?
63
+
64
+ begin
65
+ expanded_path = File.expand_path(worktree_path)
66
+
67
+ # Check if worktree exists
68
+ worktree_info = find_worktree_info(expanded_path)
69
+ return error_result("Worktree not found at #{expanded_path}") unless worktree_info
70
+
71
+ # Check for uncommitted changes
72
+ if !force && has_uncommitted_changes?(expanded_path, ignore_untracked: ignore_untracked)
73
+ return error_result("Worktree has uncommitted changes. Use --force to remove anyway.")
74
+ end
75
+
76
+ # Store branch name before removal
77
+ branch_name = worktree_info.branch
78
+
79
+ # Remove the worktree using git
80
+ # When ignore_untracked is true, we've already verified there are no tracked changes,
81
+ # so pass force: true to skip git's own untracked-file check.
82
+ result = remove_git_worktree(expanded_path, force: force || ignore_untracked)
83
+ return result unless result[:success]
84
+
85
+ # Optionally remove the directory
86
+ if remove_directory && File.exist?(expanded_path)
87
+ remove_worktree_directory(expanded_path)
88
+ end
89
+
90
+ # Optionally delete the branch
91
+ branch_deleted = false
92
+ if delete_branch && branch_name && !branch_name.empty?
93
+ delete_result = delete_branch_if_safe(branch_name, force)
94
+ branch_deleted = delete_result[:success]
95
+ end
96
+
97
+ {
98
+ success: true,
99
+ message: "Worktree removed successfully",
100
+ path: expanded_path,
101
+ branch: branch_name,
102
+ branch_deleted: branch_deleted,
103
+ error: nil
104
+ }
105
+ rescue => e
106
+ error_result("Unexpected error: #{e.message}")
107
+ end
108
+ end
109
+
110
+ # Remove a worktree by branch name
111
+ #
112
+ # @param branch_name [String] Branch name of the worktree
113
+ # @param force [Boolean] Force removal even if there are uncommitted changes
114
+ # @return [Hash] Result with :success, :message, :error
115
+ #
116
+ # @example
117
+ # result = remover.remove_by_branch("081-fix-auth")
118
+ def remove_by_branch(branch_name, force: false)
119
+ return error_result("Branch name is required") if branch_name.nil? || branch_name.empty?
120
+
121
+ # Find worktree by branch
122
+ worktree_info = find_worktree_by_branch(branch_name)
123
+ return error_result("No worktree found for branch: #{branch_name}") unless worktree_info
124
+
125
+ remove(worktree_info.path, force: force)
126
+ end
127
+
128
+ # Remove a worktree by task ID
129
+ #
130
+ # @param task_id [String] Task ID
131
+ # @param force [Boolean] Force removal even if there are uncommitted changes
132
+ # @return [Hash] Result with :success, :message, :error
133
+ #
134
+ # @example
135
+ # result = remover.remove_by_task_id("081")
136
+ def remove_by_task_id(task_id, force: false)
137
+ return error_result("Task ID is required") if task_id.nil? || task_id.empty?
138
+
139
+ # Find worktree by task ID
140
+ worktree_info = find_worktree_by_task_id(task_id)
141
+ return error_result("No worktree found for task: #{task_id}") unless worktree_info
142
+
143
+ remove(worktree_info.path, force: force)
144
+ end
145
+
146
+ # Remove multiple worktrees
147
+ #
148
+ # @param worktree_paths [Array<String>] Array of worktree paths
149
+ # @param force [Boolean] Force removal even if there are uncommitted changes
150
+ # @return [Hash] Result with :success, :removed, :failed, :errors
151
+ #
152
+ # @example
153
+ # result = remover.remove_multiple(["/path1", "/path2"], force: true)
154
+ # # => { success: true, removed: ["/path1"], failed: ["/path2"], errors: {...} }
155
+ def remove_multiple(worktree_paths, force: false)
156
+ return error_result("Worktree paths array is required") if worktree_paths.nil? || worktree_paths.empty?
157
+
158
+ results = {
159
+ success: true,
160
+ removed: [],
161
+ failed: [],
162
+ errors: {}
163
+ }
164
+
165
+ Array(worktree_paths).each do |path|
166
+ result = remove(path, force: force)
167
+ if result[:success]
168
+ results[:removed] << path
169
+ else
170
+ results[:success] = false
171
+ results[:failed] << path
172
+ results[:errors][path] = result[:error]
173
+ end
174
+ end
175
+
176
+ results
177
+ end
178
+
179
+ # Prune deleted worktrees (cleanup git metadata)
180
+ #
181
+ # @return [Hash] Result with :success, :message, :pruned_count
182
+ #
183
+ # @example
184
+ # result = remover.prune
185
+ # # => { success: true, message: "Pruned 2 worktrees", pruned_count: 2 }
186
+ def prune
187
+ result = execute_git_worktree_prune
188
+ if result[:success]
189
+ # Parse output to count pruned worktrees
190
+ pruned_count = parse_prune_output(result[:output])
191
+
192
+ {
193
+ success: true,
194
+ message: "Pruned #{pruned_count} worktree(s)",
195
+ pruned_count: pruned_count,
196
+ error: nil
197
+ }
198
+ else
199
+ error_result("Failed to prune worktrees: #{result[:error]}")
200
+ end
201
+ rescue => e
202
+ error_result("Unexpected error during prune: #{e.message}")
203
+ end
204
+
205
+ # Check if a worktree can be safely removed
206
+ #
207
+ # @param worktree_path [String] Path to the worktree
208
+ # @return [Hash] Safety check result with :safe, :warnings, :errors
209
+ #
210
+ # @example
211
+ # check = remover.check_removal_safety("/path/to/worktree")
212
+ # if check[:safe]
213
+ # remover.remove("/path/to/worktree")
214
+ # else
215
+ # puts "Cannot remove: #{check[:errors].join(', ')}"
216
+ # end
217
+ def check_removal_safety(worktree_path)
218
+ expanded_path = File.expand_path(worktree_path)
219
+
220
+ result = {
221
+ safe: true,
222
+ warnings: [],
223
+ errors: []
224
+ }
225
+
226
+ # Check if worktree exists
227
+ worktree_info = find_worktree_info(expanded_path)
228
+ unless worktree_info
229
+ result[:safe] = false
230
+ result[:errors] << "Worktree not found"
231
+ return result
232
+ end
233
+
234
+ # Check for uncommitted changes
235
+ if has_uncommitted_changes?(expanded_path)
236
+ result[:safe] = false
237
+ result[:errors] << "Worktree has uncommitted changes"
238
+ end
239
+
240
+ # Check if it's the current worktree
241
+ current_dir = Dir.pwd
242
+ if File.expand_path(current_dir) == expanded_path
243
+ result[:warnings] << "Currently in this worktree"
244
+ end
245
+
246
+ # Check if it's the main worktree
247
+ if worktree_info.branch.nil || worktree_info.detached || worktree_info.bare
248
+ result[:warnings] << "This might be the main worktree"
249
+ end
250
+
251
+ result
252
+ end
253
+
254
+ private
255
+
256
+ # Find worktree info by path
257
+ #
258
+ # @param path [String] Worktree path
259
+ # @return [WorktreeInfo, nil] Worktree info or nil
260
+ def find_worktree_info(path)
261
+ require_relative "worktree_lister"
262
+ lister = WorktreeLister.new
263
+ lister.find_by_path(path)
264
+ end
265
+
266
+ # Find worktree info by branch name
267
+ #
268
+ # @param branch_name [String] Branch name
269
+ # @return [WorktreeInfo, nil] Worktree info or nil
270
+ def find_worktree_by_branch(branch_name)
271
+ require_relative "worktree_lister"
272
+ lister = WorktreeLister.new
273
+ lister.find_by_branch(branch_name)
274
+ end
275
+
276
+ # Find worktree info by task ID
277
+ #
278
+ # @param task_id [String] Task ID
279
+ # @return [WorktreeInfo, nil] Worktree info or nil
280
+ def find_worktree_by_task_id(task_id)
281
+ require_relative "worktree_lister"
282
+ lister = WorktreeLister.new
283
+ lister.find_by_task_id(task_id)
284
+ end
285
+
286
+ # Remove worktree using git command
287
+ #
288
+ # @param worktree_path [String] Worktree path
289
+ # @return [Hash] Command result
290
+ def remove_git_worktree(worktree_path, force: false)
291
+ require_relative "../atoms/git_command"
292
+ args = ["remove"]
293
+ args << "--force" if force
294
+ args << worktree_path
295
+ result = Atoms::GitCommand.worktree(*args, timeout: @timeout)
296
+
297
+ if result[:success]
298
+ {success: true, message: "Git worktree removed successfully"}
299
+ else
300
+ error_result("Failed to remove git worktree: #{result[:error]}")
301
+ end
302
+ end
303
+
304
+ # Remove the worktree directory
305
+ #
306
+ # @param worktree_path [String] Worktree path
307
+ def remove_worktree_directory(worktree_path)
308
+ if File.exist?(worktree_path)
309
+ FileUtils.rm_rf(worktree_path)
310
+ end
311
+ rescue => e
312
+ warn "Warning: Failed to remove worktree directory: #{e.message}"
313
+ end
314
+
315
+ # Check if worktree has uncommitted changes
316
+ #
317
+ # @param worktree_path [String] Worktree path
318
+ # @param ignore_untracked [Boolean] Ignore untracked files when checking cleanliness
319
+ # @return [Boolean] true if there are uncommitted changes
320
+ def has_uncommitted_changes?(worktree_path, ignore_untracked: false)
321
+ return false unless File.exist?(worktree_path)
322
+
323
+ # Change to worktree directory and check git status
324
+ original_dir = Dir.pwd
325
+ begin
326
+ Dir.chdir(worktree_path)
327
+ status_args = ["status", "--porcelain"]
328
+ status_args << "--untracked-files=no" if ignore_untracked
329
+
330
+ result = execute_git_command(*status_args)
331
+ result[:success] && !result[:output].strip.empty?
332
+ ensure
333
+ Dir.chdir(original_dir)
334
+ end
335
+ end
336
+
337
+ # Execute git worktree prune command
338
+ #
339
+ # @return [Hash] Command result
340
+ def execute_git_worktree_prune
341
+ require_relative "../atoms/git_command"
342
+ Atoms::GitCommand.worktree("prune", timeout: @timeout)
343
+ end
344
+
345
+ # Parse prune output to count pruned worktrees
346
+ #
347
+ # @param output [String] Git prune command output
348
+ # @return [Integer] Number of pruned worktrees
349
+ def parse_prune_output(output)
350
+ return 0 if output.nil? || output.empty?
351
+
352
+ # Look for lines like "Pruning worktree /path/to/worktree"
353
+ lines = output.split("\n")
354
+ lines.count { |line| line.include?("Pruning worktree") }
355
+ end
356
+
357
+ # Execute git command
358
+ #
359
+ # @param args [Array<String>] Command arguments
360
+ # @return [Hash] Command result
361
+ def execute_git_command(*args)
362
+ require_relative "../atoms/git_command"
363
+ Atoms::GitCommand.execute(*args, timeout: @timeout)
364
+ end
365
+
366
+ # Create an error result hash
367
+ #
368
+ # @param message [String] Error message
369
+ # @return [Hash] Error result hash
370
+ def error_result(message)
371
+ {
372
+ success: false,
373
+ message: nil,
374
+ error: message
375
+ }
376
+ end
377
+
378
+ public
379
+
380
+ # Delete a branch if it's safe to do so
381
+ #
382
+ # @param branch_name [String] Branch name to delete
383
+ # @param force [Boolean] Force deletion even if not merged
384
+ # @return [Hash] Result with :success, :message, :error
385
+ def delete_branch_if_safe(branch_name, force)
386
+ require_relative "../atoms/git_command"
387
+
388
+ # Check if branch is already merged (unless forcing)
389
+ unless force
390
+ # Check if branch is merged into current branch
391
+ result = Atoms::GitCommand.execute("branch", "--merged", timeout: @timeout)
392
+ if result[:success]
393
+ merged_branches = result[:output].split("\n").map(&:strip).map { |b| b.gsub(/^\*?\s*/, "") }
394
+ unless merged_branches.include?(branch_name)
395
+ # Branch is not merged, don't delete unless forced
396
+ warn "Warning: Branch #{branch_name} is not merged. Skipping deletion. Use --force to delete anyway."
397
+ return {success: false, message: "Branch not merged", error: nil}
398
+ end
399
+ end
400
+ end
401
+
402
+ # Delete the branch
403
+ delete_flag = force ? "-D" : "-d"
404
+ result = Atoms::GitCommand.execute("branch", delete_flag, branch_name, timeout: @timeout)
405
+
406
+ if result[:success]
407
+ {success: true, message: "Branch #{branch_name} deleted", error: nil}
408
+ else
409
+ {success: false, message: nil, error: "Failed to delete branch: #{result[:error]}"}
410
+ end
411
+ end
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end