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,906 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/task_id_extractor"
4
+ require_relative "../molecules/current_task_linker"
5
+ require_relative "../molecules/parent_task_resolver"
6
+
7
+ module Ace
8
+ module Git
9
+ module Worktree
10
+ module Organisms
11
+ # Task worktree orchestrator
12
+ #
13
+ # Orchestrates the complete workflow of creating task-aware worktrees,
14
+ # including task status updates, metadata tracking, commits, and mise trust.
15
+ # This is the main high-level interface for task-worktree integration.
16
+ #
17
+ # @example Create a complete task worktree workflow
18
+ # orchestrator = TaskWorktreeOrchestrator.new
19
+ # result = orchestrator.create_for_task("081")
20
+ # # => { success: true, worktree_path: "/project/.ace-wt/task.081", ... }
21
+ class TaskWorktreeOrchestrator
22
+ # Initialize a new TaskWorktreeOrchestrator
23
+ #
24
+ # @param config [WorktreeConfig, nil] Worktree configuration (loaded if nil)
25
+ # @param project_root [String] Project root directory
26
+ def initialize(config: nil, project_root: Dir.pwd)
27
+ @project_root = project_root
28
+ @config = config || load_configuration
29
+ @task_fetcher = Molecules::TaskFetcher.new
30
+ @task_status_updater = Molecules::TaskStatusUpdater.new
31
+ @task_committer = Molecules::TaskCommitter.new
32
+ @task_pusher = Molecules::TaskPusher.new
33
+ @worktree_creator = Molecules::WorktreeCreator.new
34
+ @pr_creator = Molecules::PrCreator.new
35
+ @parent_task_resolver = Molecules::ParentTaskResolver.new(project_root: project_root)
36
+ end
37
+
38
+ # Create a worktree for a task with complete workflow
39
+ #
40
+ # @param task_ref [String] Task reference (081, task.081, v.0.9.0+081)
41
+ # @param options [Hash] Options for worktree creation
42
+ # @option options [String] :source Git ref to use as branch start-point (default: current branch)
43
+ # @return [Hash] Result with workflow details
44
+ #
45
+ # @example
46
+ # orchestrator = TaskWorktreeOrchestrator.new
47
+ # result = orchestrator.create_for_task("081")
48
+ # # => {
49
+ # # success: true,
50
+ # # worktree_path: "/project/.ace-wt/task.081",
51
+ # # branch: "081-fix-authentication-bug",
52
+ # # task_id: "081",
53
+ # # steps_completed: ["task_fetched", "status_updated", "worktree_created", "mise_trusted"],
54
+ # # error: nil
55
+ # # }
56
+ #
57
+ # @example With explicit source
58
+ # result = orchestrator.create_for_task("081", source: "main")
59
+ # # => Creates branch based on 'main' instead of current branch
60
+ def create_for_task(task_ref, options = {})
61
+ workflow_result = initialize_workflow_result
62
+
63
+ begin
64
+ # Step 1: Fetch task data
65
+ task_data = fetch_task_data(task_ref)
66
+ return error_workflow_result("Task not found: #{task_ref}", workflow_result) unless task_data
67
+
68
+ task_id = extract_task_id(task_data)
69
+ workflow_result[:task_id] = task_id
70
+ workflow_result[:task_title] = task_data[:title]
71
+ workflow_result[:steps_completed] << "task_fetched"
72
+
73
+ # Step 2: Check if worktree already exists
74
+ existing_worktree = check_existing_worktree(task_data)
75
+ if existing_worktree
76
+ return success_workflow_result("Worktree already exists", workflow_result.merge(
77
+ worktree_path: existing_worktree.path,
78
+ branch: existing_worktree.branch,
79
+ existing: true
80
+ ))
81
+ end
82
+
83
+ # Step 3: Update task status if configured (and not overridden)
84
+ should_update_status = options[:no_status_update] ? false : @config.auto_mark_in_progress?
85
+ if should_update_status && task_data[:status] != "in-progress"
86
+ status_result = update_task_status(task_id, "in-progress")
87
+ if status_result[:success]
88
+ workflow_result[:steps_completed] << "status_updated"
89
+ else
90
+ error_message = status_result[:message] || "Failed to update task status"
91
+ hint = "\n\nHint: Use --no-status-update to create worktree without changing task status"
92
+ return error_workflow_result(error_message + hint, workflow_result)
93
+ end
94
+ end
95
+
96
+ # Step 4: Create worktree metadata
97
+ # Determine target branch for PR (parent's branch for subtasks, or main)
98
+ target_branch = resolve_target_branch(task_data, options)
99
+ workflow_result[:target_branch] = target_branch
100
+ worktree_metadata = create_worktree_metadata(task_data, target_branch: target_branch)
101
+ workflow_result[:steps_completed] << "metadata_prepared"
102
+
103
+ # Step 5: Add worktree metadata to task file (BEFORE commit so it's included)
104
+ if @config.add_worktree_metadata?
105
+ if add_worktree_metadata_to_task(task_data, worktree_metadata)
106
+ workflow_result[:steps_completed] << "metadata_added"
107
+ else
108
+ workflow_result[:warnings] << "Failed to add worktree metadata to task"
109
+ end
110
+ end
111
+
112
+ # Step 6: Commit task changes if configured (includes status + metadata)
113
+ # Commit when either status was updated or metadata was added
114
+ should_commit = options[:no_commit] ? false : @config.auto_commit_task?
115
+ metadata_was_added = workflow_result[:steps_completed].include?("metadata_added")
116
+ has_changes_to_commit = should_update_status || metadata_was_added
117
+ if should_commit && has_changes_to_commit
118
+ commit_message = options[:commit_message] || "in-progress"
119
+ if commit_task_changes(task_data, commit_message)
120
+ workflow_result[:steps_completed] << "task_committed"
121
+ else
122
+ # Continue even if commit fails, but note it
123
+ workflow_result[:warnings] << "Failed to commit task changes"
124
+ end
125
+ end
126
+
127
+ # Step 7: Push task changes if configured (so PR shows updates)
128
+ should_push = options[:no_push] ? false : @config.auto_push_task?
129
+ if should_push && should_commit && workflow_result[:steps_completed].include?("task_committed")
130
+ push_remote = options[:push_remote] || @config.push_remote
131
+ if push_task_changes(push_remote)
132
+ workflow_result[:steps_completed] << "task_pushed"
133
+ workflow_result[:pushed_to] = push_remote
134
+ else
135
+ # Continue even if push fails, but note it
136
+ workflow_result[:warnings] << "Failed to push task changes"
137
+ end
138
+ end
139
+
140
+ # Step 8: Create the worktree
141
+ worktree_result = create_worktree_for_task(task_data, worktree_metadata, source: options[:source])
142
+ return error_workflow_result(worktree_result[:error], workflow_result) unless worktree_result[:success]
143
+
144
+ workflow_result[:worktree_path] = worktree_result[:worktree_path]
145
+ workflow_result[:branch] = worktree_result[:branch]
146
+ workflow_result[:directory_name] = worktree_result[:directory_name]
147
+ workflow_result[:start_point] = worktree_result[:start_point]
148
+ workflow_result[:steps_completed] << "worktree_created"
149
+
150
+ # Step 8.5: Create _current symlink in worktree if configured
151
+ if @config.create_current_symlink?
152
+ current_linker = Molecules::CurrentTaskLinker.new(
153
+ project_root: worktree_result[:worktree_path],
154
+ symlink_name: @config.current_symlink_name
155
+ )
156
+ # Task directory relative to worktree (same structure as main repo)
157
+ # task_data[:path] is the task file path, we need the parent directory
158
+ worktree_task_dir = File.dirname(File.join(worktree_result[:worktree_path], relative_task_path(task_data[:path])))
159
+ link_result = current_linker.link(worktree_task_dir)
160
+ if link_result[:success]
161
+ workflow_result[:steps_completed] << "current_symlink_created"
162
+ workflow_result[:current_symlink] = link_result[:symlink_path]
163
+ else
164
+ # Symlink creation is non-blocking - failure becomes warning
165
+ workflow_result[:warnings] ||= []
166
+ workflow_result[:warnings] << "Failed to create _current symlink: #{link_result[:error]}"
167
+ end
168
+ end
169
+
170
+ # Step 9: Setup upstream for worktree branch if configured
171
+ should_setup_upstream = @config.auto_setup_upstream? && !options[:no_upstream]
172
+ if should_setup_upstream
173
+ upstream_result = setup_upstream_for_worktree(worktree_result, options)
174
+ if upstream_result[:success]
175
+ workflow_result[:steps_completed] << "upstream_setup"
176
+ workflow_result[:pushed_branch] = upstream_result[:branch]
177
+ else
178
+ # Upstream setup is non-blocking - failure becomes warning
179
+ workflow_result[:warnings] ||= []
180
+ workflow_result[:warnings] << "Failed to setup upstream: #{upstream_result[:error]}"
181
+ end
182
+ end
183
+
184
+ # Step 9.5: Add started_at timestamp to task IN WORKTREE (creates initial commit for PR)
185
+ # Only do this if we're going to create a PR and upstream succeeded
186
+ upstream_succeeded = workflow_result[:steps_completed].include?("upstream_setup")
187
+ should_create_pr = @config.auto_create_pr? && !options[:no_pr]
188
+ if should_create_pr && upstream_succeeded
189
+ started_result = add_started_timestamp_in_worktree(task_data, worktree_result, options)
190
+ if started_result[:success]
191
+ workflow_result[:steps_completed] << "started_at_added"
192
+ else
193
+ # Non-blocking - PR creation may still work if branch already has commits
194
+ workflow_result[:warnings] ||= []
195
+ workflow_result[:warnings] << "Failed to add started_at: #{started_result[:error]}"
196
+ end
197
+ end
198
+
199
+ # Step 10: Create draft PR if configured
200
+ if should_create_pr && upstream_succeeded
201
+ pr_result = create_pr_for_task(task_data, worktree_result, options)
202
+ if pr_result[:success]
203
+ workflow_result[:steps_completed] << "pr_created"
204
+ workflow_result[:pr_number] = pr_result[:pr_number]
205
+ workflow_result[:pr_url] = pr_result[:pr_url]
206
+ workflow_result[:pr_existing] = pr_result[:existing]
207
+
208
+ # Step 11: Save PR metadata to task
209
+ save_pr_result = save_pr_to_task(task_data, pr_result)
210
+ if save_pr_result
211
+ workflow_result[:steps_completed] << "pr_saved_to_task"
212
+ else
213
+ workflow_result[:warnings] ||= []
214
+ workflow_result[:warnings] << "Failed to save PR metadata to task"
215
+ end
216
+ else
217
+ # PR creation is non-blocking - failure becomes warning
218
+ workflow_result[:warnings] ||= []
219
+ workflow_result[:warnings] << "Failed to create PR: #{pr_result[:error]}"
220
+ end
221
+ elsif should_create_pr && !upstream_succeeded
222
+ # Skip PR creation if upstream setup failed
223
+ workflow_result[:warnings] ||= []
224
+ workflow_result[:warnings] << "Skipped PR creation: branch not pushed to remote"
225
+ end
226
+
227
+ # Step 12: Run after-create hooks if configured
228
+ hooks = @config.after_create_hooks
229
+ if hooks && hooks.any?
230
+ require_relative "../molecules/hook_executor"
231
+ hook_executor = Molecules::HookExecutor.new
232
+ hook_result = hook_executor.execute_hooks(
233
+ hooks,
234
+ worktree_path: worktree_result[:worktree_path],
235
+ project_root: @project_root,
236
+ task_data: task_data
237
+ )
238
+
239
+ if hook_result[:success]
240
+ workflow_result[:steps_completed] << "hooks_executed"
241
+ workflow_result[:hooks_results] = hook_result[:results]
242
+ else
243
+ # Hooks are non-blocking - failures become warnings
244
+ workflow_result[:warnings] ||= []
245
+ workflow_result[:warnings] += hook_result[:errors]
246
+ workflow_result[:hooks_results] = hook_result[:results]
247
+ end
248
+ end
249
+
250
+ # Success!
251
+ success_workflow_result("Task worktree created successfully", workflow_result)
252
+ rescue => e
253
+ error_workflow_result("Unexpected error: #{e.message}", workflow_result)
254
+ end
255
+ end
256
+
257
+ # Create a worktree with dry run (no actual changes)
258
+ #
259
+ # @param task_ref [String] Task reference
260
+ # @param options [Hash] Options for dry run
261
+ # @return [Hash] Dry run result showing what would be done
262
+ #
263
+ # @example
264
+ # result = orchestrator.dry_run_create("081")
265
+ # # => { success: true, would_create: {...}, steps: [...] }
266
+ def dry_run_create(task_ref, options = {})
267
+ workflow_result = initialize_workflow_result
268
+
269
+ begin
270
+ # Step 1: Fetch task data
271
+ task_data = fetch_task_data(task_ref)
272
+ return error_workflow_result("Task not found: #{task_ref}", workflow_result) unless task_data
273
+
274
+ task_id = extract_task_id(task_data)
275
+ workflow_result[:task_id] = task_id
276
+ workflow_result[:task_title] = task_data[:title]
277
+ workflow_result[:steps_completed] << "task_fetched"
278
+
279
+ # Step 2: Check what would be created
280
+ directory_name = @config.format_directory(task_data)
281
+ branch_name = @config.format_branch(task_data)
282
+ worktree_path = File.join(@config.absolute_root_path, directory_name)
283
+ options[:source] || "main"
284
+
285
+ # Determine target branch for PR (parent's branch for subtasks, or main)
286
+ target_branch = resolve_target_branch(task_data, options)
287
+
288
+ # Determine upstream/PR settings (considering options)
289
+ should_setup_upstream = @config.auto_setup_upstream? && !options[:no_upstream]
290
+ should_create_pr = @config.auto_create_pr? && !options[:no_pr] && should_setup_upstream
291
+
292
+ # Determine if there will be changes to commit
293
+ # Commit when either status would be updated or metadata would be added
294
+ would_update_status = @config.auto_mark_in_progress? && task_data[:status] != "in-progress"
295
+ would_add_metadata = @config.add_worktree_metadata?
296
+ has_changes_to_commit = would_update_status || would_add_metadata
297
+ would_commit = @config.auto_commit_task? && has_changes_to_commit
298
+
299
+ # Determine if current symlink would be created (in worktree)
300
+ would_create_current_symlink = @config.create_current_symlink?
301
+ current_symlink_path = would_create_current_symlink ? File.join(worktree_path, @config.current_symlink_name) : nil
302
+ # Task directory relative to worktree (same structure as main repo)
303
+ # task_data[:path] is the task file path, we need the parent directory
304
+ relative_task = would_create_current_symlink ? relative_task_path(task_data[:path]) : nil
305
+ worktree_task_dir = would_create_current_symlink ? File.dirname(File.join(worktree_path, relative_task)) : nil
306
+
307
+ workflow_result[:would_create] = {
308
+ worktree_path: worktree_path,
309
+ branch: branch_name,
310
+ directory_name: directory_name,
311
+ target_branch: target_branch,
312
+ task_status_update: would_update_status,
313
+ metadata_addition: would_add_metadata,
314
+ task_commit: would_commit,
315
+ task_push: @config.auto_push_task? && would_commit,
316
+ push_remote: @config.push_remote,
317
+ current_symlink: would_create_current_symlink,
318
+ current_symlink_path: current_symlink_path,
319
+ current_symlink_target: worktree_task_dir,
320
+ upstream_push: should_setup_upstream,
321
+ add_started_at: should_create_pr && should_setup_upstream,
322
+ create_pr: should_create_pr,
323
+ pr_title: should_create_pr ? @config.format_pr_title(task_data) : nil,
324
+ pr_base: should_create_pr ? target_branch : nil,
325
+ hooks_count: @config.after_create_hooks.length
326
+ }
327
+
328
+ workflow_result[:steps_planned] = [
329
+ "fetch_task_data",
330
+ ("update_task_status" if workflow_result[:would_create][:task_status_update]),
331
+ ("add_worktree_metadata" if workflow_result[:would_create][:metadata_addition]),
332
+ ("commit_task_changes" if workflow_result[:would_create][:task_commit]),
333
+ ("push_to_#{workflow_result[:would_create][:push_remote]}" if workflow_result[:would_create][:task_push]),
334
+ "create_worktree",
335
+ ("create_current_symlink" if workflow_result[:would_create][:current_symlink]),
336
+ ("setup_upstream_tracking" if should_setup_upstream),
337
+ ("add_started_at_in_worktree" if workflow_result[:would_create][:add_started_at]),
338
+ ("create_draft_pr" if should_create_pr),
339
+ ("save_pr_metadata" if should_create_pr),
340
+ ("execute_#{workflow_result[:would_create][:hooks_count]}_hooks" if workflow_result[:would_create][:hooks_count] > 0)
341
+ ].compact
342
+
343
+ success_workflow_result("Dry run completed", workflow_result)
344
+ rescue => e
345
+ error_workflow_result("Dry run error: #{e.message}", workflow_result)
346
+ end
347
+ end
348
+
349
+ # Remove a task worktree with cleanup
350
+ #
351
+ # @param task_ref [String] Task reference
352
+ # @param options [Hash] Options for removal
353
+ # @return [Hash] Result of removal workflow
354
+ #
355
+ # @example
356
+ # result = orchestrator.remove_task_worktree("081", force: true)
357
+ def remove_task_worktree(task_ref, options = {})
358
+ workflow_result = initialize_workflow_result
359
+
360
+ begin
361
+ # Step 1: Fetch task data
362
+ task_data = fetch_task_data(task_ref)
363
+
364
+ # Step 2: Find existing worktree (with fallback for missing task)
365
+ if task_data
366
+ task_id = extract_task_id(task_data)
367
+ workflow_result[:task_id] = task_id
368
+ workflow_result[:steps_completed] << "task_fetched"
369
+ worktree_info = find_worktree_for_task_data(task_data)
370
+ else
371
+ # Fallback: Try to find worktree by task reference even if task data not found
372
+ worktree_info = find_worktree_by_task_reference(task_ref)
373
+ if worktree_info
374
+ puts "Task not found, but worktree found. Removing worktree without updating task data."
375
+ workflow_result[:task_id] = task_ref
376
+ workflow_result[:task_not_found] = true
377
+ else
378
+ return error_workflow_result("Task not found: #{task_ref}", workflow_result)
379
+ end
380
+ end
381
+
382
+ return error_workflow_result("No worktree found for task", workflow_result) unless worktree_info
383
+
384
+ workflow_result[:worktree_path] = worktree_info.path
385
+ workflow_result[:branch] = worktree_info.branch
386
+
387
+ # Step 3: Check removal safety
388
+ worktree_remover = Molecules::WorktreeRemover.new
389
+ safety_check = worktree_remover.check_removal_safety(worktree_info.path)
390
+ unless options[:force] || safety_check[:safe]
391
+ return error_workflow_result("Cannot remove worktree: #{safety_check[:errors].join(", ")}", workflow_result)
392
+ end
393
+
394
+ # Step 4: Remove worktree metadata from task (only if task was found)
395
+ if task_data && remove_worktree_metadata_from_task(task_data)
396
+ workflow_result[:steps_completed] << "metadata_removed"
397
+ elsif workflow_result[:task_not_found]
398
+ workflow_result[:steps_completed] << "skipped_metadata_cleanup"
399
+ end
400
+
401
+ # Step 5: Remove the worktree
402
+ remove_result = worktree_remover.remove(worktree_info.path, force: options[:force])
403
+ return error_workflow_result("Failed to remove worktree: #{remove_result[:error]}", workflow_result) unless remove_result[:success]
404
+
405
+ workflow_result[:steps_completed] << "worktree_removed"
406
+
407
+ success_workflow_result("Task worktree removed successfully", workflow_result)
408
+ rescue => e
409
+ error_workflow_result("Unexpected error: #{e.message}", workflow_result)
410
+ end
411
+ end
412
+
413
+ # Get status of task worktrees
414
+ #
415
+ # @param task_refs [Array<String>, nil] Task references to check (all if nil)
416
+ # @return [Hash] Status information
417
+ #
418
+ # @example
419
+ # status = orchestrator.get_task_worktree_status(["081", "082"])
420
+ def get_task_worktree_status(task_refs = nil)
421
+ if task_refs.nil?
422
+ # Get all task-associated worktrees
423
+ worktree_lister = Molecules::WorktreeLister.new
424
+ worktrees = worktree_lister.list_all.select(&:task_associated?)
425
+ task_ids = worktrees.map(&:task_id).compact.uniq
426
+ else
427
+ task_ids = Array(task_refs).map { |ref| Atoms::TaskIDExtractor.normalize(ref) }.compact
428
+ end
429
+
430
+ status_info = {
431
+ total_tasks: task_ids.length,
432
+ worktrees: []
433
+ }
434
+
435
+ task_ids.each do |task_id|
436
+ worktree_info = @worktree_creator.find_by_task_id(task_id)
437
+ task_metadata = @task_fetcher.fetch(task_id)
438
+
439
+ worktree_status = {
440
+ task_id: task_id,
441
+ task_title: task_metadata&.title,
442
+ task_status: task_metadata&.status,
443
+ has_worktree: !worktree_info.nil?,
444
+ worktree_path: worktree_info&.path,
445
+ worktree_branch: worktree_info&.branch,
446
+ worktree_exists: worktree_info&.exists?,
447
+ worktree_usable: worktree_info&.usable?
448
+ }
449
+
450
+ status_info[:worktrees] << worktree_status
451
+ end
452
+
453
+ status_info[:worktrees_with_worktrees] = status_info[:worktrees].count { |w| w[:has_worktree] }
454
+ status_info[:active_worktrees] = status_info[:worktrees].count { |w| w[:worktree_exists] && w[:worktree_usable] }
455
+
456
+ {success: true, status: status_info}
457
+ rescue => e
458
+ error_result("Failed to get task worktree status: #{e.message}")
459
+ end
460
+
461
+ private
462
+
463
+ # Find worktree by task reference (fallback when task metadata not found)
464
+ #
465
+ # @param task_ref [String] Task reference (e.g., "090", "task.090")
466
+ # @return [WorktreeInfo, nil] Worktree info or nil if not found
467
+ def find_worktree_by_task_reference(task_ref)
468
+ # Get all worktrees using WorktreeLister
469
+ worktree_lister = Molecules::WorktreeLister.new
470
+ worktrees = worktree_lister.list_all
471
+
472
+ # Normalize task reference to match worktree IDs
473
+ normalized_id = normalize_task_id_for_matching(task_ref)
474
+
475
+ # Find worktree with matching task ID
476
+ worktrees.find do |worktree|
477
+ worktree.task_id == normalized_id
478
+ end
479
+ end
480
+
481
+ # Normalize task ID for worktree matching
482
+ #
483
+ # @param task_ref [String] Task reference (e.g., "090", "121.01", "task.121.01")
484
+ # @return [String] Normalized task ID (preserves subtask suffix)
485
+ def normalize_task_id_for_matching(task_ref)
486
+ # Use shared extractor that preserves subtask IDs (e.g., "121.01")
487
+ Atoms::TaskIDExtractor.normalize(task_ref) || task_ref
488
+ end
489
+
490
+ # Initialize workflow result structure
491
+ #
492
+ # @return [Hash] Initial workflow result
493
+ def initialize_workflow_result
494
+ {
495
+ success: false,
496
+ task_id: nil,
497
+ task_title: nil,
498
+ worktree_path: nil,
499
+ branch: nil,
500
+ directory_name: nil,
501
+ steps_completed: [],
502
+ steps_planned: [],
503
+ warnings: [],
504
+ error: nil,
505
+ existing: false
506
+ }
507
+ end
508
+
509
+ # Load configuration
510
+ #
511
+ # @return [WorktreeConfig] Loaded configuration
512
+ def load_configuration
513
+ loader = Molecules::ConfigLoader.new(@project_root)
514
+ loader.load
515
+ end
516
+
517
+ # Fetch task data
518
+ #
519
+ # @param task_ref [String] Task reference
520
+ # @return [Hash, nil] Task data hash or nil
521
+ def fetch_task_data(task_ref)
522
+ @task_fetcher.fetch(task_ref)
523
+ end
524
+
525
+ # Resolve target branch for PR
526
+ #
527
+ # Uses CLI-provided target_branch if present, otherwise auto-detects from parent task.
528
+ # For subtasks, returns parent's worktree branch. For orchestrators, returns "main".
529
+ #
530
+ # @param task_data [Hash] Task data hash from ace-task
531
+ # @param options [Hash] Options hash (may contain :target_branch)
532
+ # @return [String] Target branch name
533
+ def resolve_target_branch(task_data, options)
534
+ options[:target_branch] || @parent_task_resolver.resolve_target_branch(task_data)
535
+ end
536
+
537
+ # Check if worktree already exists for task
538
+ #
539
+ # @param task_data [Hash] Task data hash
540
+ # @return [WorktreeInfo, nil] Existing worktree or nil
541
+ def check_existing_worktree(task_data)
542
+ @worktree_creator.worktree_exists?(task_data: task_data)
543
+ end
544
+
545
+ # Update task status
546
+ #
547
+ # @param task_id [String] Task ID
548
+ # @param status [String] New status
549
+ # @return [Hash] Result with :success and :message keys
550
+ def update_task_status(task_id, status)
551
+ @task_status_updater.update_status(task_id, status)
552
+ end
553
+
554
+ # Create worktree metadata
555
+ #
556
+ # @param task_data [Hash] Task data hash from ace-task
557
+ # @param target_branch [String, nil] PR target branch (for subtasks)
558
+ # @return [WorktreeMetadata] Worktree metadata
559
+ def create_worktree_metadata(task_data, target_branch: nil)
560
+ # Generate worktree path and branch names
561
+ directory_name = @config.format_directory(task_data)
562
+ branch_name = @config.format_branch(task_data)
563
+ File.join(@config.absolute_root_path, directory_name)
564
+
565
+ Models::WorktreeMetadata.new(
566
+ branch: branch_name,
567
+ path: File.join(@config.root_path, directory_name),
568
+ target_branch: target_branch,
569
+ created_at: Time.now
570
+ )
571
+ end
572
+
573
+ # Commit task changes
574
+ #
575
+ # @param task_data [Hash] Task data hash from ace-task
576
+ # @param status [String] Task status
577
+ # @return [Boolean] true if successful
578
+ def commit_task_changes(task_data, status)
579
+ # Find the task file (this would need implementation)
580
+ # For now, commit all changes
581
+ task_id = extract_task_id(task_data)
582
+ @task_committer.commit_all_changes(status, task_id)
583
+ end
584
+
585
+ # Push task changes to remote
586
+ #
587
+ # @param remote [String] Remote name (default: "origin")
588
+ # @return [Boolean] true if successful
589
+ def push_task_changes(remote = "origin")
590
+ result = @task_pusher.push(remote: remote)
591
+ result[:success]
592
+ end
593
+
594
+ # Create worktree for task
595
+ #
596
+ # @param task_data [Hash] Task data hash from ace-task
597
+ # @param worktree_metadata [WorktreeMetadata] Worktree metadata
598
+ # @param source [String, nil] Git ref to use as branch start-point
599
+ # @return [Hash] Worktree creation result
600
+ def create_worktree_for_task(task_data, worktree_metadata, source: nil)
601
+ @worktree_creator.create_for_task(
602
+ task_data,
603
+ @config,
604
+ source: source,
605
+ target_branch: worktree_metadata.target_branch
606
+ )
607
+ end
608
+
609
+ # Add worktree metadata to task
610
+ #
611
+ # @param task_data [Hash] Task data hash from ace-task
612
+ # @param worktree_metadata [WorktreeMetadata] Worktree metadata
613
+ # @return [Boolean] true if successful
614
+ def add_worktree_metadata_to_task(task_data, worktree_metadata)
615
+ # Try to use ace-task update command first
616
+ task_id = extract_task_id(task_data)
617
+ if @task_status_updater.add_worktree_metadata(task_id, worktree_metadata)
618
+ return true
619
+ end
620
+
621
+ # Fallback to direct file manipulation
622
+ # This would need implementation to find and update the task file
623
+ false
624
+ end
625
+
626
+ # Find worktree for task
627
+ #
628
+ # @param task_data [Hash] Task data hash from ace-task
629
+ # @return [WorktreeInfo, nil] Worktree info or nil
630
+ def find_worktree_for_task_data(task_data)
631
+ task_id = extract_task_id(task_data)
632
+ @worktree_creator.find_by_task_id(task_id)
633
+ end
634
+
635
+ # Remove worktree metadata from task
636
+ #
637
+ # @param task_data [Hash] Task data hash from ace-task
638
+ # @return [Boolean] true if successful
639
+ def remove_worktree_metadata_from_task(task_data)
640
+ # This would need implementation to find and update the task file
641
+ false
642
+ end
643
+
644
+ # Setup upstream tracking for worktree branch
645
+ #
646
+ # Pushes the new branch to remote with -u flag to setup upstream tracking.
647
+ # If push fails but remote branch exists, falls back to git branch --set-upstream-to.
648
+ # Uses the worktree path to run git commands from within the worktree.
649
+ #
650
+ # @param worktree_result [Hash] Worktree creation result with :worktree_path, :branch
651
+ # @param options [Hash] Options hash (may include :push_remote)
652
+ # @return [Hash] Result with :success, :branch, :remote, :error, :method
653
+ def setup_upstream_for_worktree(worktree_result, options)
654
+ worktree_path = worktree_result[:worktree_path]
655
+ branch = worktree_result[:branch]
656
+ remote = options[:push_remote] || @config.push_remote || "origin"
657
+
658
+ begin
659
+ Dir.chdir(worktree_path) do
660
+ # Try push with -u first
661
+ result = @task_pusher.push(remote: remote, set_upstream: true)
662
+
663
+ if result[:success]
664
+ return {
665
+ success: true,
666
+ branch: branch,
667
+ remote: remote,
668
+ error: nil,
669
+ method: :push
670
+ }
671
+ end
672
+
673
+ # Push failed - check if remote branch exists and set upstream directly
674
+ if remote_branch_exists?(remote, branch)
675
+ upstream_result = @task_pusher.set_upstream(branch: branch, remote: remote)
676
+ if upstream_result[:success]
677
+ return {
678
+ success: true,
679
+ branch: branch,
680
+ remote: remote,
681
+ error: nil,
682
+ method: :set_upstream
683
+ }
684
+ end
685
+ end
686
+
687
+ # Both methods failed
688
+ {
689
+ success: false,
690
+ branch: branch,
691
+ remote: remote,
692
+ error: result[:error] || "Failed to setup upstream"
693
+ }
694
+ end
695
+ rescue => e
696
+ {
697
+ success: false,
698
+ branch: branch,
699
+ remote: remote,
700
+ error: e.message
701
+ }
702
+ end
703
+ end
704
+
705
+ # Check if a branch exists on the remote
706
+ #
707
+ # @param remote [String] Remote name (e.g., "origin")
708
+ # @param branch [String] Branch name to check
709
+ # @return [Boolean] true if remote branch exists
710
+ def remote_branch_exists?(remote, branch)
711
+ result = Atoms::GitCommand.execute("ls-remote", "--heads", remote, branch, timeout: 10)
712
+ result[:success] && result[:output]&.include?(branch)
713
+ end
714
+
715
+ # Create draft PR for task
716
+ #
717
+ # Creates a draft PR targeting the source branch (start_point) from which
718
+ # the worktree branch was created.
719
+ #
720
+ # @param task_data [Hash] Task data hash from ace-task
721
+ # @param worktree_result [Hash] Worktree creation result with :branch, :start_point
722
+ # @param options [Hash] Options (may include :source for base branch override)
723
+ # @return [Hash] Result with :success, :pr_number, :pr_url, :existing, :error
724
+ def create_pr_for_task(task_data, worktree_result, options)
725
+ branch = worktree_result[:branch]
726
+ # Use target_branch from metadata if available (for subtasks)
727
+ # Otherwise fall back to source option or start_point
728
+ start_point = worktree_result[:target_branch] || options[:source] || worktree_result[:start_point]
729
+ title = @config.format_pr_title(task_data)
730
+
731
+ # Resolve base branch - handle SHA vs branch name
732
+ base = resolve_pr_base(start_point, options)
733
+
734
+ # Create draft PR
735
+ @pr_creator.create_draft(
736
+ branch: branch,
737
+ base: base,
738
+ title: title
739
+ )
740
+ end
741
+
742
+ # Resolve PR base branch from start_point
743
+ #
744
+ # If start_point is a commit SHA (not a branch name), creates a branch
745
+ # on remote for that SHA to use as PR base.
746
+ #
747
+ # @param start_point [String, nil] Branch name or commit SHA
748
+ # @param options [Hash] Options (may include :push_remote)
749
+ # @return [String] Branch name to use as PR base
750
+ def resolve_pr_base(start_point, options)
751
+ return "main" unless start_point
752
+
753
+ # SHA patterns: 40 hex chars (full) or 7+ hex chars (abbreviated)
754
+ if start_point.match?(/\A[0-9a-f]{7,40}\z/i)
755
+ # start_point is a commit SHA - create a branch on remote for it
756
+ create_remote_branch_for_sha(start_point, options)
757
+ else
758
+ start_point
759
+ end
760
+ end
761
+
762
+ # Create a branch on remote for a commit SHA
763
+ #
764
+ # @param sha [String] Commit SHA
765
+ # @param options [Hash] Options (may include :push_remote)
766
+ # @return [String] Branch name (either new branch or "main" on failure)
767
+ def create_remote_branch_for_sha(sha, options)
768
+ base_branch = "base-#{sha[0..6]}"
769
+ remote = options[:push_remote] || @config.push_remote || "origin"
770
+
771
+ # Push the SHA as a new branch: git push origin SHA:refs/heads/base-abc1234
772
+ result = Atoms::GitCommand.execute("push", remote, "#{sha}:refs/heads/#{base_branch}")
773
+
774
+ if result[:success]
775
+ base_branch
776
+ else
777
+ warn "Warning: Failed to create base branch for SHA #{sha}, using 'main' as PR base"
778
+ "main"
779
+ end
780
+ end
781
+
782
+ # Save PR metadata to task file
783
+ #
784
+ # @param task_data [Hash] Task data hash from ace-task
785
+ # @param pr_result [Hash] PR creation result with :pr_number, :pr_url
786
+ # @return [Boolean] true if successful
787
+ def save_pr_to_task(task_data, pr_result)
788
+ task_id = extract_task_id(task_data)
789
+
790
+ pr_data = {
791
+ number: pr_result[:pr_number],
792
+ url: pr_result[:pr_url],
793
+ created_at: Time.now
794
+ }
795
+
796
+ @task_status_updater.add_pr_metadata(task_id, pr_data)
797
+ end
798
+
799
+ # Add started_at timestamp to task file IN WORKTREE
800
+ #
801
+ # This creates an initial commit in the worktree branch, enabling PR creation
802
+ # (GitHub requires at least one commit difference between branches for a PR).
803
+ #
804
+ # @param task_data [Hash] Task data hash from ace-task
805
+ # @param worktree_result [Hash] Worktree creation result with :worktree_path
806
+ # @param options [Hash] Options (may include :push_remote)
807
+ # @return [Hash] Result with :success, :error
808
+ def add_started_timestamp_in_worktree(task_data, worktree_result, options)
809
+ worktree_path = worktree_result[:worktree_path]
810
+ task_id = extract_task_id(task_data)
811
+ remote = options[:push_remote] || @config.push_remote || "origin"
812
+
813
+ Dir.chdir(worktree_path) do
814
+ # Set PROJECT_ROOT_PATH to worktree so TaskManager updates the right files
815
+ # (otherwise it finds and updates the main project's task files)
816
+ original_project_root = ENV["PROJECT_ROOT_PATH"]
817
+ ENV["PROJECT_ROOT_PATH"] = worktree_path
818
+
819
+ begin
820
+ # Update task file with started_at
821
+ if @task_status_updater.add_started_at_timestamp(task_id)
822
+ # Commit the change
823
+ if @task_committer.commit_all_changes("started", task_id)
824
+ # Push to remote
825
+ result = @task_pusher.push(remote: remote)
826
+ return result
827
+ else
828
+ return {success: false, error: "Failed to commit started_at change"}
829
+ end
830
+ else
831
+ return {success: false, error: "Failed to update task file with started_at"}
832
+ end
833
+ ensure
834
+ # Restore original PROJECT_ROOT_PATH
835
+ if original_project_root
836
+ ENV["PROJECT_ROOT_PATH"] = original_project_root
837
+ else
838
+ ENV.delete("PROJECT_ROOT_PATH")
839
+ end
840
+ end
841
+ end
842
+ rescue => e
843
+ {success: false, error: e.message}
844
+ end
845
+
846
+ # Create success workflow result
847
+ #
848
+ # @param message [String] Success message
849
+ # @param workflow_result [Hash] Workflow result to update
850
+ # @return [Hash] Updated workflow result
851
+ def success_workflow_result(message, workflow_result)
852
+ workflow_result.merge(
853
+ success: true,
854
+ message: message
855
+ )
856
+ end
857
+
858
+ # Create error workflow result
859
+ #
860
+ # @param error_message [String] Error message
861
+ # @param workflow_result [Hash] Workflow result to update
862
+ # @return [Hash] Updated workflow result
863
+ def error_workflow_result(error_message, workflow_result)
864
+ workflow_result.merge(
865
+ success: false,
866
+ error: error_message
867
+ )
868
+ end
869
+
870
+ # Extract task ID from task data
871
+ #
872
+ # @param task_data [Hash] Task data hash
873
+ # @return [String] Task ID (e.g., "094")
874
+ def extract_task_id(task_data)
875
+ # Use shared extractor that preserves subtask IDs (e.g., "121.01")
876
+ Atoms::TaskIDExtractor.extract(task_data)
877
+ end
878
+
879
+ # Get relative task path from absolute path
880
+ #
881
+ # Extracts the relative path portion from an absolute task path.
882
+ # E.g., "/project/.ace-task/v.0.9.0/tasks/145-feat/" -> ".ace-task/v.0.9.0/tasks/145-feat/"
883
+ #
884
+ # @param absolute_path [String] Absolute path to task directory
885
+ # @return [String] Relative path from project root
886
+ def relative_task_path(absolute_path)
887
+ return absolute_path unless absolute_path&.start_with?("/")
888
+
889
+ Pathname.new(absolute_path).relative_path_from(Pathname.new(@project_root)).to_s
890
+ end
891
+
892
+ # Create error result
893
+ #
894
+ # @param message [String] Error message
895
+ # @return [Hash] Error result
896
+ def error_result(message)
897
+ {
898
+ success: false,
899
+ error: message
900
+ }
901
+ end
902
+ end
903
+ end
904
+ end
905
+ end
906
+ end