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,502 @@
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 Models
9
+ # Configuration model for worktree settings
10
+ #
11
+ # Represents the configuration loaded from .ace/git/worktree.yml
12
+ # with defaults, validation, and accessors for all configuration options.
13
+ #
14
+ # @example Create configuration with defaults
15
+ # config = WorktreeConfig.new
16
+ # config.root_path # => ".ace-wt"
17
+ #
18
+ # @example Load from configuration hash
19
+ # config = WorktreeConfig.new({
20
+ # "git" => {
21
+ # "worktree" => {
22
+ # "root_path" => "~/worktrees",
23
+ # "mise_trust_auto" => false
24
+ # }
25
+ # }
26
+ # })
27
+ class WorktreeConfig
28
+ # Default configuration values
29
+ DEFAULT_CONFIG = {
30
+ "root_path" => ".ace-wt",
31
+ "auto_navigate" => true,
32
+ "tmux" => false,
33
+ "mise_trust_auto" => true,
34
+ "task" => {
35
+ "directory_format" => "t.{task_id}",
36
+ "branch_format" => "{id}-{slug}",
37
+ "auto_mark_in_progress" => true,
38
+ "auto_commit_task" => true,
39
+ "auto_push_task" => true,
40
+ "push_remote" => "origin",
41
+ "commit_message_format" => "chore({task_id}): mark as in-progress, creating worktree for {slug}",
42
+ "add_worktree_metadata" => true,
43
+ "auto_setup_upstream" => false,
44
+ "auto_create_pr" => false,
45
+ "pr_title_format" => "{id} - {slug}",
46
+ "create_current_symlink" => true,
47
+ "current_symlink_name" => "_current"
48
+ },
49
+ "pr" => {
50
+ "directory_format" => "ace-pr-{number}",
51
+ "branch_format" => "pr-{number}-{slug}",
52
+ "remote_name" => "origin",
53
+ "fetch_before_create" => true,
54
+ "configure_push_for_mismatch" => true
55
+ },
56
+ "branch" => {
57
+ "fetch_if_remote" => true,
58
+ "auto_detect_remote" => true
59
+ },
60
+ "cleanup" => {
61
+ "on_merge" => false,
62
+ "on_delete" => true
63
+ },
64
+ "hooks" => {
65
+ "after_create" => []
66
+ }
67
+ }.freeze
68
+
69
+ # Configuration namespace paths
70
+ CONFIG_NAMESPACE = ["git", "worktree"].freeze
71
+
72
+ attr_reader :root_path, :auto_navigate, :tmux, :mise_trust_auto, :task_config, :pr_config, :branch_config, :cleanup_config, :hooks_config
73
+
74
+ # Initialize a new WorktreeConfig
75
+ #
76
+ # @param config_hash [Hash] Configuration hash (typically from ace-core)
77
+ # @param project_root [String] Project root directory for relative paths
78
+ def initialize(config_hash = {}, project_root = Dir.pwd)
79
+ @project_root = project_root
80
+ @raw_config = extract_worktree_config(config_hash)
81
+ @merged_config = merge_with_defaults(@raw_config)
82
+
83
+ initialize_attributes
84
+ end
85
+
86
+ # Get the directory format for task-based worktrees
87
+ #
88
+ # @return [String] Directory format template
89
+ def directory_format
90
+ @task_config["directory_format"]
91
+ end
92
+
93
+ # Get the branch format for task-based worktrees
94
+ #
95
+ # @return [String] Branch format template
96
+ def branch_format
97
+ @task_config["branch_format"]
98
+ end
99
+
100
+ # Check if auto-navigation should be performed
101
+ #
102
+ # @return [Boolean] true if auto-navigation should be performed
103
+ def auto_navigate?
104
+ @auto_navigate
105
+ end
106
+
107
+ # Check if tmux session should be launched after worktree creation
108
+ #
109
+ # @return [Boolean] true if tmux launch is enabled
110
+ def tmux?
111
+ @tmux
112
+ end
113
+
114
+ # Check if mise should automatically trust worktree directories
115
+ #
116
+ # @return [Boolean] true if mise auto-trust is enabled
117
+ def mise_trust_auto?
118
+ @mise_trust_auto
119
+ end
120
+
121
+ # Check if tasks should be marked as in-progress automatically
122
+ #
123
+ # @return [Boolean] true if tasks should be marked in-progress
124
+ def auto_mark_in_progress?
125
+ @task_config["auto_mark_in_progress"]
126
+ end
127
+
128
+ # Check if task changes should be committed automatically
129
+ #
130
+ # @return [Boolean] true if task changes should be committed
131
+ def auto_commit_task?
132
+ @task_config["auto_commit_task"]
133
+ end
134
+
135
+ # Check if task changes should be pushed automatically
136
+ #
137
+ # @return [Boolean] true if task changes should be pushed
138
+ def auto_push_task?
139
+ @task_config["auto_push_task"] != false
140
+ end
141
+
142
+ # Get the remote for pushing task changes
143
+ #
144
+ # @return [String] Remote name (default: "origin")
145
+ def push_remote
146
+ @task_config["push_remote"] || "origin"
147
+ end
148
+
149
+ # Get the commit message format for task updates
150
+ #
151
+ # @return [String] Commit message template
152
+ def commit_message_format
153
+ @task_config["commit_message_format"]
154
+ end
155
+
156
+ # Check if worktree metadata should be added to tasks
157
+ #
158
+ # @return [Boolean] true if metadata should be added
159
+ def add_worktree_metadata?
160
+ @task_config["add_worktree_metadata"]
161
+ end
162
+
163
+ # Check if new worktree branch should be pushed to remote with upstream tracking
164
+ #
165
+ # @return [Boolean] true if upstream setup is enabled
166
+ def auto_setup_upstream?
167
+ @task_config["auto_setup_upstream"]
168
+ end
169
+
170
+ # Check if draft PR should be created automatically
171
+ #
172
+ # @return [Boolean] true if auto PR creation is enabled
173
+ def auto_create_pr?
174
+ @task_config["auto_create_pr"]
175
+ end
176
+
177
+ # Get the PR title format template
178
+ #
179
+ # @return [String] PR title format template
180
+ def pr_title_format
181
+ @task_config["pr_title_format"]
182
+ end
183
+
184
+ # Check if _current symlink should be created
185
+ #
186
+ # @return [Boolean] true if symlink should be created
187
+ def create_current_symlink?
188
+ @task_config["create_current_symlink"] != false
189
+ end
190
+
191
+ # Get the name for the _current symlink
192
+ #
193
+ # @return [String] Symlink name (default: "_current")
194
+ def current_symlink_name
195
+ @task_config["current_symlink_name"] || "_current"
196
+ end
197
+
198
+ # Format a PR title using task data
199
+ #
200
+ # @param task_data [Hash] Task data hash from ace-task
201
+ # @return [String] Formatted PR title
202
+ #
203
+ # @example
204
+ # config.format_pr_title(task) # => "081 - fix-authentication-bug"
205
+ def format_pr_title(task_data)
206
+ template = pr_title_format
207
+ apply_template_variables(template, task_data)
208
+ end
209
+
210
+ # Get the root path for worktrees (expanded and absolute)
211
+ #
212
+ # @return [String] Absolute path to worktree root directory
213
+ def absolute_root_path
214
+ @absolute_root_path ||= expand_root_path
215
+ end
216
+
217
+ # Check if worktrees should be cleaned up on branch merge
218
+ #
219
+ # @return [Boolean] true if cleanup on merge
220
+ def cleanup_on_merge?
221
+ @cleanup_config["on_merge"]
222
+ end
223
+
224
+ # Check if worktrees should be cleaned up on branch delete
225
+ #
226
+ # @return [Boolean] true if cleanup on delete
227
+ def cleanup_on_delete?
228
+ @cleanup_config["on_delete"]
229
+ end
230
+
231
+ # Get after-create hooks configuration
232
+ #
233
+ # @return [Array<Hash>] Array of hook definitions
234
+ def after_create_hooks
235
+ @hooks_config["after_create"] || []
236
+ end
237
+
238
+ # Check if push should be configured for PR branches with name mismatches
239
+ #
240
+ # @return [Boolean] true if push should be configured for mismatched branch names
241
+ def configure_push_for_mismatch?
242
+ @pr_config["configure_push_for_mismatch"]
243
+ end
244
+
245
+ # Check if hooks are configured
246
+ #
247
+ # @return [Boolean] true if any hooks are configured
248
+ def hooks_enabled?
249
+ hooks = after_create_hooks
250
+ hooks.is_a?(Array) && hooks.any?
251
+ end
252
+
253
+ # Format a directory path using task data
254
+ #
255
+ # @param task_data [Hash] Task data hash from ace-task
256
+ # @param counter [Integer, nil] Counter for multiple worktrees of same task
257
+ # @return [String] Formatted directory path
258
+ #
259
+ # @example
260
+ # config.format_directory(task) # => "t.081"
261
+ # config.format_directory(task, 2) # => "t.081-2"
262
+ def format_directory(task_data, counter = nil)
263
+ template = directory_format
264
+ formatted = apply_template_variables(template, task_data)
265
+
266
+ # Add counter if provided
267
+ formatted = "#{formatted}-#{counter}" if counter
268
+
269
+ formatted
270
+ end
271
+
272
+ # Format a branch name using task data
273
+ #
274
+ # @param task_data [Hash] Task data hash from ace-task
275
+ # @return [String] Formatted branch name
276
+ #
277
+ # @example
278
+ # config.format_branch(task) # => "081-fix-authentication-bug"
279
+ def format_branch(task_data)
280
+ template = branch_format
281
+ apply_template_variables(template, task_data)
282
+ end
283
+
284
+ # Format a commit message for task updates
285
+ #
286
+ # @param task_data [Hash] Task data hash from ace-task
287
+ # @return [String] Formatted commit message
288
+ #
289
+ # @example
290
+ # config.format_commit_message(task) # => "chore(task-081): mark as in-progress, creating worktree"
291
+ def format_commit_message(task_data)
292
+ template = commit_message_format
293
+ apply_template_variables(template, task_data)
294
+ end
295
+
296
+ # Validate configuration settings
297
+ #
298
+ # @return [Array<String>] Array of validation error messages (empty if valid)
299
+ def validate
300
+ errors = []
301
+
302
+ # Validate root_path
303
+ unless root_path.is_a?(String) && !root_path.empty?
304
+ errors << "root_path must be a non-empty string"
305
+ end
306
+
307
+ # Validate task template formats
308
+ unless directory_format.is_a?(String) && !directory_format.empty?
309
+ errors << "task.directory_format must be a non-empty string"
310
+ end
311
+
312
+ unless branch_format.is_a?(String) && !branch_format.empty?
313
+ errors << "task.branch_format must be a non-empty string"
314
+ end
315
+
316
+ # Validate PR template formats
317
+ pr_dir_format = @pr_config["directory_format"]
318
+ unless pr_dir_format.is_a?(String) && !pr_dir_format.empty?
319
+ errors << "pr.directory_format must be a non-empty string"
320
+ end
321
+
322
+ pr_branch_format = @pr_config["branch_format"]
323
+ unless pr_branch_format.is_a?(String) && !pr_branch_format.empty?
324
+ errors << "pr.branch_format must be a non-empty string"
325
+ end
326
+
327
+ # Validate template variables for task configuration
328
+ task_templates = {
329
+ "task.directory_format" => {template: directory_format, valid_vars: %w[task_id id slug]},
330
+ "task.branch_format" => {template: branch_format, valid_vars: %w[id slug task_id]},
331
+ "task.commit_message_format" => {template: commit_message_format, valid_vars: %w[task_id slug id]}
332
+ }
333
+
334
+ task_templates.each do |name, config|
335
+ invalid_vars = find_invalid_template_variables(config[:template], config[:valid_vars])
336
+ if invalid_vars.any?
337
+ errors << "#{name} contains invalid variables: #{invalid_vars.join(", ")}. Valid variables: #{config[:valid_vars].map { |v| "{#{v}}" }.join(", ")}"
338
+ end
339
+
340
+ # Warn if template has no variables (except for commit_message_format which is optional)
341
+ unless name == "task.commit_message_format"
342
+ if config[:template] && !config[:template].match?(/\{[^}]+\}/)
343
+ errors << "#{name} should include at least one template variable from: #{config[:valid_vars].map { |v| "{#{v}}" }.join(", ")}"
344
+ end
345
+ end
346
+ end
347
+
348
+ # Validate template variables for PR configuration
349
+ pr_templates = {
350
+ "pr.directory_format" => {template: pr_dir_format, valid_vars: %w[number slug title base_branch]},
351
+ "pr.branch_format" => {template: pr_branch_format, valid_vars: %w[number slug title base_branch]}
352
+ }
353
+
354
+ pr_templates.each do |name, config|
355
+ invalid_vars = find_invalid_template_variables(config[:template], config[:valid_vars])
356
+ if invalid_vars.any?
357
+ errors << "#{name} contains invalid variables: #{invalid_vars.join(", ")}. Valid variables: #{config[:valid_vars].map { |v| "{#{v}}" }.join(", ")}"
358
+ end
359
+ end
360
+
361
+ errors
362
+ end
363
+
364
+ # Get configuration as a hash
365
+ #
366
+ # @return [Hash] Configuration hash
367
+ def to_h
368
+ {
369
+ root_path: root_path,
370
+ auto_navigate: auto_navigate?,
371
+ mise_trust_auto: mise_trust_auto?,
372
+ task: @task_config.dup,
373
+ cleanup: @cleanup_config.dup,
374
+ hooks: @hooks_config.dup
375
+ }
376
+ end
377
+
378
+ private
379
+
380
+ # Find invalid template variables in a template string
381
+ #
382
+ # @param template [String] Template string with {variable} placeholders
383
+ # @param valid_vars [Array<String>] List of valid variable names
384
+ # @return [Array<String>] List of invalid variable names
385
+ def find_invalid_template_variables(template, valid_vars)
386
+ return [] unless template.is_a?(String)
387
+
388
+ # Extract all variables from template
389
+ template_vars = template.scan(/\{(\w+)\}/).flatten
390
+
391
+ # Find variables that are not in the valid list
392
+ template_vars.uniq.reject { |var| valid_vars.include?(var) }
393
+ end
394
+
395
+ # Extract worktree configuration from nested config hash
396
+ #
397
+ # @param config_hash [Hash] Full configuration hash
398
+ # @return [Hash] Worktree-specific configuration
399
+ def extract_worktree_config(config_hash)
400
+ CONFIG_NAMESPACE.reduce(config_hash) do |current, key|
401
+ current&.dig(key) || {}
402
+ end
403
+ end
404
+
405
+ # Merge configuration with defaults
406
+ #
407
+ # @param config [Hash] User configuration
408
+ # @return [Hash] Merged configuration
409
+ def merge_with_defaults(config)
410
+ deep_merge(DEFAULT_CONFIG.dup, config)
411
+ end
412
+
413
+ # Deep merge two hashes
414
+ #
415
+ # @param target [Hash] Target hash
416
+ # @param source [Hash] Source hash
417
+ # @return [Hash] Merged hash
418
+ def deep_merge(target, source)
419
+ target.merge(source) do |key, old_val, new_val|
420
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
421
+ deep_merge(old_val, new_val)
422
+ else
423
+ new_val.nil? ? old_val : new_val
424
+ end
425
+ end
426
+ end
427
+
428
+ # Initialize instance attributes from merged configuration
429
+ def initialize_attributes
430
+ @root_path = @merged_config["root_path"]
431
+ @auto_navigate = @merged_config["auto_navigate"]
432
+ @tmux = @merged_config["tmux"]
433
+ @mise_trust_auto = @merged_config["mise_trust_auto"]
434
+ @task_config = @merged_config["task"] || {}
435
+ @pr_config = @merged_config["pr"] || {}
436
+ @branch_config = @merged_config["branch"] || {}
437
+ @cleanup_config = @merged_config["cleanup"] || {}
438
+ @hooks_config = @merged_config["hooks"] || {}
439
+ end
440
+
441
+ # Expand root path to absolute path
442
+ #
443
+ # @return [String] Absolute path
444
+ def expand_root_path
445
+ require_relative "../atoms/path_expander"
446
+ Atoms::PathExpander.expand(@root_path, @project_root)
447
+ end
448
+
449
+ # Apply template variables to a format string
450
+ #
451
+ # @param template [String] Template string with {variable} placeholders
452
+ # @param task_data [Hash] Task data hash from ace-task
453
+ # @return [String] Formatted string with variables replaced
454
+ def apply_template_variables(template, task_data)
455
+ formatted = template.dup
456
+
457
+ # Extract task number from ID for backward compatibility
458
+ task_id = extract_task_number(task_data)
459
+
460
+ # Available template variables
461
+ variables = {
462
+ "id" => task_id,
463
+ "task_id" => task_id,
464
+ "slug" => create_slug(task_data[:title] || "unknown-task")
465
+ }
466
+
467
+ # Replace each variable
468
+ variables.each do |key, value|
469
+ formatted = formatted.gsub("{#{key}}", value.to_s)
470
+ end
471
+
472
+ formatted
473
+ end
474
+
475
+ # Extract task number from task data
476
+ #
477
+ # @param task_data [Hash] Task data hash
478
+ # @return [String] Task number (e.g., "094")
479
+ def extract_task_number(task_data)
480
+ # Use shared extractor that preserves subtask IDs (e.g., "121.01")
481
+ Atoms::TaskIDExtractor.extract(task_data)
482
+ end
483
+
484
+ # Create URL-friendly slug from title
485
+ #
486
+ # @param title [String] Task title
487
+ # @return [String] URL-friendly slug
488
+ def create_slug(title)
489
+ return "unknown-task" unless title
490
+
491
+ # Convert to lowercase, replace spaces and special chars with hyphens
492
+ title.downcase
493
+ .gsub(/[^a-z0-9\s-]/, "") # Remove special chars except spaces and hyphens
494
+ .gsub(/\s+/, "-").squeeze("-") # Replace multiple hyphens with single
495
+ .gsub(/^-|-$/, "") # Remove leading/trailing hyphens
496
+ .tap { |slug| slug.empty? ? "unknown-task" : slug }
497
+ end
498
+ end
499
+ end
500
+ end
501
+ end
502
+ end