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,522 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/task_id_extractor"
4
+
5
+ module Ace
6
+ module Git
7
+ module Worktree
8
+ module Commands
9
+ # Remove command
10
+ #
11
+ # Removes worktrees with safety checks and cleanup options.
12
+ # Supports both task-aware and traditional worktree removal.
13
+ #
14
+ # @example Remove by task ID
15
+ # RemoveCommand.new.run(["--task", "081"])
16
+ #
17
+ # @example Remove by branch name
18
+ # RemoveCommand.new.run(["feature-branch"])
19
+ #
20
+ # @example Force remove
21
+ # RemoveCommand.new.run(["--task", "081", "--force"])
22
+ class RemoveCommand
23
+ # Initialize a new RemoveCommand
24
+ def initialize
25
+ @manager = Organisms::WorktreeManager.new
26
+ @task_fetcher = Molecules::TaskFetcher.new
27
+ end
28
+
29
+ # Run the remove command
30
+ #
31
+ # @param args [Array<String>] Command arguments
32
+ # @return [Integer] Exit code (0 for success, 1 for error)
33
+ def run(args = [])
34
+ options = parse_arguments(args)
35
+ return show_help if options[:help]
36
+
37
+ validate_options(options)
38
+
39
+ if options[:task]
40
+ remove_task_worktree(options)
41
+ else
42
+ remove_traditional_worktree(options)
43
+ end
44
+ rescue ArgumentError => e
45
+ puts "Error: #{e.message}"
46
+ puts
47
+ show_help
48
+ 1
49
+ rescue => e
50
+ puts "Error: #{e.message}"
51
+ 1
52
+ end
53
+
54
+ # Show help for the remove command
55
+ #
56
+ # @return [Integer] Exit code
57
+ def show_help
58
+ puts <<~HELP
59
+ ace-git-worktree remove - Remove a worktree
60
+
61
+ USAGE:
62
+ ace-git-worktree remove <identifier> [OPTIONS]
63
+ ace-git-worktree remove --task <task-id> [OPTIONS]
64
+
65
+ IDENTIFIERS:
66
+ Task ID: 081, task.081, v.0.9.0+081
67
+ Branch name: feature-branch, main
68
+ Directory name: task.081, feature-branch
69
+ Full path: /path/to/worktree
70
+
71
+ OPTIONS:
72
+ --task <task-id> Remove worktree for specific task
73
+ --force Force removal even with uncommitted changes
74
+ --keep-directory Keep the worktree directory (default: remove)
75
+ --delete-branch, -db Also delete the associated branch
76
+ --dry-run Show what would be removed without removing
77
+ --help, -h Show this help message
78
+
79
+ EXAMPLES:
80
+ # Remove task worktree
81
+ ace-git-worktree remove --task 081
82
+
83
+ # Remove by branch name
84
+ ace-git-worktree remove feature-branch
85
+
86
+ # Force remove with changes
87
+ ace-git-worktree remove --task 081 --force
88
+
89
+ # Dry run to see what would be removed
90
+ ace-git-worktree remove --task 081 --dry-run
91
+
92
+ # Remove but keep directory
93
+ ace-git-worktree remove --task 081 --keep-directory
94
+
95
+ SAFETY:
96
+ • The command checks for uncommitted changes
97
+ • Use --force to remove worktrees with changes
98
+ • Task removal also cleans up task metadata
99
+ • Main worktree cannot be removed accidentally
100
+
101
+ CONFIGURATION:
102
+ Worktree removal respects settings in .ace/git/worktree.yml
103
+ HELP
104
+ 0
105
+ end
106
+
107
+ private
108
+
109
+ # Parse command line arguments
110
+ #
111
+ # @param args [Array<String>] Command arguments
112
+ # @return [Hash] Parsed options
113
+ def parse_arguments(args)
114
+ options = {
115
+ task: nil,
116
+ identifier: nil,
117
+ force: false,
118
+ keep_directory: false,
119
+ delete_branch: false,
120
+ dry_run: false,
121
+ help: false
122
+ }
123
+
124
+ i = 0
125
+ while i < args.length
126
+ arg = args[i]
127
+
128
+ case arg
129
+ when "--task"
130
+ i += 1
131
+ options[:task] = args[i]
132
+ when "--force"
133
+ options[:force] = true
134
+ when "--keep-directory"
135
+ options[:keep_directory] = true
136
+ when "--delete-branch", "-db"
137
+ options[:delete_branch] = true
138
+ when "--dry-run"
139
+ options[:dry_run] = true
140
+ when "--help", "-h"
141
+ options[:help] = true
142
+ when /^--/
143
+ raise ArgumentError, "Unknown option: #{arg}"
144
+ else
145
+ # Positional argument - worktree identifier
146
+ if options[:identifier]
147
+ raise ArgumentError, "Multiple identifiers specified: #{options[:identifier]} and #{arg}"
148
+ end
149
+ options[:identifier] = arg
150
+ end
151
+
152
+ i += 1
153
+ end
154
+
155
+ options
156
+ end
157
+
158
+ # Validate parsed options
159
+ #
160
+ # @param options [Hash] Parsed options
161
+ def validate_options(options)
162
+ if options[:task] && options[:identifier]
163
+ raise ArgumentError, "Cannot specify both --task and identifier"
164
+ end
165
+
166
+ if !options[:task] && !options[:identifier]
167
+ raise ArgumentError, "Must specify either --task <task-id> or <identifier>"
168
+ end
169
+
170
+ if options[:task] && options[:task].empty?
171
+ raise ArgumentError, "Task ID cannot be empty"
172
+ end
173
+
174
+ if options[:identifier] && options[:identifier].empty?
175
+ raise ArgumentError, "Identifier cannot be empty"
176
+ end
177
+
178
+ # Security validation for identifiers
179
+ if options[:identifier] && contains_dangerous_patterns?(options[:identifier])
180
+ raise ArgumentError, "Identifier contains potentially dangerous characters"
181
+ end
182
+
183
+ # Security validation for task IDs
184
+ if options[:task] && contains_dangerous_patterns?(options[:task])
185
+ raise ArgumentError, "Task ID contains potentially dangerous characters"
186
+ end
187
+ end
188
+
189
+ # Check if a string contains dangerous patterns
190
+ #
191
+ # @param value [String] Value to check
192
+ # @return [Boolean] true if dangerous patterns found
193
+ def contains_dangerous_patterns?(value)
194
+ return false if value.nil?
195
+
196
+ dangerous_patterns = [
197
+ /;/, # Command separator
198
+ /\|/, # Pipe
199
+ /`/, # Backtick command substitution
200
+ /\$\(/, # Command substitution
201
+ /\.\.\//, # Path traversal
202
+ /&&/, # AND operator
203
+ /\|\|/, # OR operator
204
+ /\x00/ # Null byte
205
+ ]
206
+
207
+ dangerous_patterns.any? { |pattern| value.match?(pattern) }
208
+ end
209
+
210
+ # Remove a task-aware worktree
211
+ #
212
+ # @param options [Hash] Command options
213
+ # @return [Integer] Exit code
214
+ def remove_task_worktree(options)
215
+ puts "Removing worktree for task: #{options[:task]}"
216
+
217
+ # Try to find task data first
218
+ task_data = @task_fetcher.fetch(options[:task])
219
+ task_found = false
220
+ worktree_info = nil
221
+
222
+ if task_data
223
+ puts "Task found: #{task_data[:title]} (status: #{task_data[:status]})"
224
+ worktree_info = find_worktree_for_task(task_data)
225
+ task_found = true
226
+ else
227
+ # Fallback: Try to find worktree by task reference (for cases where task metadata exists but worktree doesn't)
228
+ puts "Task not found in ace-task, checking for orphaned worktree..."
229
+ worktree_info = find_worktree_by_task_reference(options[:task])
230
+ task_found = false
231
+ end
232
+
233
+ unless worktree_info
234
+ # Even if worktree doesn't exist, try to clean up the branch if requested
235
+ if task_data
236
+ puts "Task found but no worktree is associated with it."
237
+ puts "Task: #{task_data[:title]} (status: #{task_data[:status]})"
238
+
239
+ # Try to find the orphaned branch
240
+ branch_name = find_branch_for_task(task_data, options[:task])
241
+ if branch_name
242
+ puts "Found orphaned branch: #{branch_name}"
243
+
244
+ # Only delete if user requested it
245
+ if options[:delete_branch]
246
+ # Use safe deletion method (same as main flow)
247
+ remover = Ace::Git::Worktree::Molecules::WorktreeRemover.new
248
+ delete_result = remover.send(:delete_branch_if_safe, branch_name, options[:force])
249
+
250
+ if delete_result[:success]
251
+ puts "Deleted branch: #{branch_name}"
252
+ return 0
253
+ else
254
+ puts "Warning: Branch '#{branch_name}' was not deleted"
255
+ puts "Note: Use --force to delete unmerged branches"
256
+ end
257
+ else
258
+ puts "Note: Branch '#{branch_name}' still exists. Use --delete-branch to remove it."
259
+ end
260
+ end
261
+
262
+ puts ""
263
+ puts "Use 'ace-git-worktree list' to see available worktrees"
264
+ else
265
+ puts "Error: Task not found: #{options[:task]}"
266
+ puts "Use 'ace-task list' to see available tasks"
267
+ end
268
+ return 1
269
+ end
270
+
271
+ if options[:dry_run]
272
+ puts "DRY RUN - No changes will be made"
273
+ puts "This would:"
274
+ puts " • Remove worktree and its metadata from task #{options[:task]}"
275
+ puts " • #{task_found ? "Clean up task file metadata" : "Skip task metadata cleanup (no worktree metadata found)"}"
276
+ puts " • #{options[:keep_directory] ? "Keep" : "Remove"} the worktree directory"
277
+ return 0
278
+ end
279
+
280
+ # Prepare removal options
281
+ {
282
+ force: options[:force],
283
+ remove_directory: !options[:keep_directory]
284
+ }.compact
285
+
286
+ if options[:dry_run]
287
+ puts "DRY RUN - No changes will be made"
288
+ puts "This would:"
289
+ puts " • Remove worktree and its metadata from task #{options[:task]}"
290
+ puts " • #{task_found ? "Clean up task file metadata" : "Skip task metadata cleanup (task not found)"}"
291
+ puts " • #{options[:keep_directory] ? "Keep" : "Remove"} the worktree directory"
292
+ return 0
293
+ end
294
+
295
+ # Remove the worktree using direct git worktree command
296
+ # This bypasses the problematic safety check in WorktreeRemover
297
+ puts "Removing worktree at: #{worktree_info.path}"
298
+
299
+ begin
300
+ # Use GitCommand atom for safe execution
301
+ git_result = Ace::Git::Worktree::Atoms::GitCommand.worktree("remove", worktree_info.path, "--force")
302
+
303
+ if git_result[:success]
304
+ puts "Worktree removed successfully!"
305
+ puts "Worktree path: #{worktree_info.path}"
306
+ puts "Branch: #{worktree_info.branch}"
307
+
308
+ # Delete the branch if requested and it exists (using safe deletion method)
309
+ if options[:delete_branch] && worktree_info.branch && !worktree_info.branch.empty?
310
+ # Use WorktreeRemover's safe deletion method for consistency
311
+ remover = Ace::Git::Worktree::Molecules::WorktreeRemover.new
312
+ delete_result = remover.send(:delete_branch_if_safe, worktree_info.branch, options[:force])
313
+
314
+ if delete_result[:success]
315
+ puts "Deleted branch: #{worktree_info.branch}"
316
+ else
317
+ # Branch deletion failed (likely unmerged without --force)
318
+ puts "Warning: Branch '#{worktree_info.branch}' was not deleted"
319
+ puts "Note: Use --force to delete unmerged branches"
320
+ end
321
+ elsif worktree_info.branch && !worktree_info.branch.empty?
322
+ puts "\nNote: Branch '#{worktree_info.branch}' still exists. Use --delete-branch to remove it."
323
+ end
324
+
325
+ # Provide appropriate messaging based on task status
326
+ if task_found && task_data
327
+ status = task_data[:status].to_s.strip
328
+ if status.include?("done") || status.include?("completed")
329
+ puts "\nTask completed: no metadata cleanup needed"
330
+ else
331
+ puts "\nNote: Task metadata cleanup not available for this task status"
332
+ end
333
+ end
334
+ 0
335
+ else
336
+ puts "Failed to remove worktree: #{git_result[:error]}"
337
+ 1
338
+ end
339
+ rescue => e
340
+ puts "Error removing worktree: #{e.message}"
341
+ 1
342
+ end
343
+ end
344
+
345
+ # Remove a traditional worktree
346
+ #
347
+ # @param options [Hash] Command options
348
+ # @return [Integer] Exit code
349
+ def remove_traditional_worktree(options)
350
+ puts "Removing worktree: #{options[:identifier]}"
351
+
352
+ if options[:dry_run]
353
+ puts "DRY RUN - No changes will be made"
354
+ puts "This would remove the worktree and #{options[:keep_directory] ? "keep" : "remove"} its directory"
355
+ return 0
356
+ end
357
+
358
+ # Prepare removal options
359
+ removal_options = {
360
+ force: options[:force],
361
+ remove_directory: !options[:keep_directory],
362
+ delete_branch: options[:delete_branch]
363
+ }.compact
364
+
365
+ # Remove the worktree
366
+ result = @manager.remove(options[:identifier], removal_options)
367
+
368
+ if result[:success]
369
+ display_traditional_removal_result(result, options)
370
+ 0
371
+ else
372
+ puts "Failed to remove worktree: #{result[:error]}"
373
+ display_removal_hints(options[:identifier], result[:error])
374
+ 1
375
+ end
376
+ end
377
+
378
+ # Display task worktree removal result
379
+ #
380
+ # @param result [Hash] Removal result
381
+ def display_task_removal_result(result)
382
+ puts "\nTask worktree removed successfully!"
383
+ puts "Task ID: #{result[:task_id]}"
384
+ puts "Worktree path: #{result[:worktree_path]}" if result[:worktree_path]
385
+ puts "Branch: #{result[:branch]}" if result[:branch]
386
+ puts "\nSteps completed:"
387
+ result[:steps_completed].each_with_index do |step, i|
388
+ puts " ✓ #{step.tr("_", " ")}"
389
+ end
390
+
391
+ puts "\nNote: Task metadata has been cleaned up."
392
+ end
393
+
394
+ # Display traditional worktree removal result
395
+ #
396
+ # @param result [Hash] Removal result
397
+ # @param options [Hash] Command options
398
+ def display_traditional_removal_result(result, options = {})
399
+ puts "\nWorktree removed successfully!"
400
+ puts "Worktree path: #{result[:path]}" if result[:path]
401
+ puts "Branch: #{result[:branch]}" if result[:branch]
402
+
403
+ if result[:branch_deleted]
404
+ puts "Branch deleted: #{result[:branch]}"
405
+ elsif result[:branch] && !result[:branch_deleted]
406
+ # This message is shown if the branch exists but wasn't deleted,
407
+ # which happens if --delete-branch was not provided, or if the
408
+ # branch deletion failed (e.g., unmerged branch without --force)
409
+ puts "\nNote: Branch '#{result[:branch]}' still exists. Use --delete-branch to remove it."
410
+ end
411
+ end
412
+
413
+ # Display removal hints and suggestions
414
+ #
415
+ # @param identifier [String] The identifier that failed
416
+ # @param error [String] The error message
417
+ def display_removal_hints(identifier, error)
418
+ if error.include?("not found")
419
+ puts "\nWorktree not found. Available worktrees:"
420
+ list_result = @manager.list_all(format: :simple)
421
+ if list_result[:success] && list_result[:worktrees].any?
422
+ list_result[:worktrees].each do |worktree|
423
+ prefix = worktree.task_associated? ? "Task #{worktree.task_id}: " : ""
424
+ puts " #{prefix}#{worktree.branch || "detached"} (#{worktree.path})"
425
+ end
426
+ else
427
+ puts " No worktrees found."
428
+ end
429
+ elsif error.include?("uncommitted changes")
430
+ puts "\nWorktree has uncommitted changes. Options:"
431
+ puts " • Use --force to remove anyway (changes will be lost)"
432
+ puts " • Commit or stash changes first"
433
+ puts " • Check worktree status with: git status"
434
+ else
435
+ puts "\nSuggestions:"
436
+ puts " • Check the worktree identifier spelling"
437
+ puts " • Use 'ace-git-worktree list' to see available worktrees"
438
+ puts " • Use --force if you're sure about removal"
439
+ end
440
+ end
441
+
442
+ # Find worktree by task reference (for completed tasks)
443
+ #
444
+ # @param task_ref [String] Task reference
445
+ # @return [WorktreeInfo, nil] Worktree info or nil if not found
446
+ def find_worktree_by_task_reference(task_ref)
447
+ worktree_lister = Ace::Git::Worktree::Molecules::WorktreeLister.new
448
+ worktrees = worktree_lister.list_all
449
+
450
+ # Normalize task reference to match worktree IDs
451
+ normalized_id = normalize_task_id_for_matching(task_ref)
452
+
453
+ # Find worktree with matching task ID
454
+ worktrees.find do |worktree|
455
+ worktree.task_id == normalized_id
456
+ end
457
+ end
458
+
459
+ # Find worktree for task (when task data is available)
460
+ #
461
+ # @param task_data [Hash] Task data hash from ace-task
462
+ # @return [WorktreeInfo, nil] Worktree info or nil if not found
463
+ def find_worktree_for_task(task_data)
464
+ worktree_lister = Ace::Git::Worktree::Molecules::WorktreeLister.new
465
+ worktrees = worktree_lister.list_all
466
+
467
+ # Extract task number from task data
468
+ task_number = extract_task_number(task_data)
469
+
470
+ worktrees.find do |worktree|
471
+ worktree.task_id == task_number ||
472
+ worktree.task_id == task_data[:id] ||
473
+ worktree.branch == task_data[:branch]
474
+ end
475
+ end
476
+
477
+ # Extract task number from task data
478
+ #
479
+ # @param task_data [Hash] Task data
480
+ # @return [String] Task number
481
+ def extract_task_number(task_data)
482
+ # Use shared extractor that preserves subtask IDs (e.g., "121.01")
483
+ Atoms::TaskIDExtractor.extract(task_data)
484
+ end
485
+
486
+ # Normalize task ID for worktree matching
487
+ #
488
+ # @param task_ref [String] Task reference
489
+ # @return [String] Normalized task ID
490
+ def normalize_task_id_for_matching(task_ref)
491
+ # Use shared extractor that preserves subtask IDs (e.g., "121.01")
492
+ Atoms::TaskIDExtractor.normalize(task_ref) || task_ref
493
+ end
494
+
495
+ # Find branch associated with a task
496
+ #
497
+ # @param task_data [Hash] Task data
498
+ # @param task_ref [String] Task reference
499
+ # @return [String, nil] Branch name or nil if not found
500
+ def find_branch_for_task(task_data, task_ref)
501
+ # Get all branches
502
+ branches_result = Ace::Git::Worktree::Atoms::GitCommand.execute("branch", "--format=%(refname:short)")
503
+ return nil unless branches_result[:success]
504
+
505
+ branches = branches_result[:output].split("\n").map(&:strip)
506
+ task_number = extract_task_number(task_data)
507
+
508
+ # Try to find branch matching task patterns
509
+ # Pattern 1: 052-task-title
510
+ # Pattern 2: task-052
511
+ # Pattern 3: v.0.9.0-052
512
+ branches.find do |branch|
513
+ branch.start_with?("#{task_number}-", "task-#{task_number}") ||
514
+ branch =~ /\d+-#{task_number}-/ ||
515
+ branch.include?("-#{task_number}-")
516
+ end
517
+ end
518
+ end
519
+ end
520
+ end
521
+ end
522
+ end