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,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/task_id_extractor"
4
+ require_relative "../atoms/git_command"
5
+ require_relative "task_fetcher"
6
+
7
+ module Ace
8
+ module Git
9
+ module Worktree
10
+ module Molecules
11
+ # Parent task resolver molecule
12
+ #
13
+ # Determines the target branch for a task's PR by resolving the parent task's
14
+ # worktree branch. This enables subtasks to target their orchestrator branch
15
+ # instead of defaulting to main.
16
+ #
17
+ # @example Resolve target branch for a subtask
18
+ # resolver = ParentTaskResolver.new
19
+ # target = resolver.resolve_target_branch(subtask_data)
20
+ # target # => "202-rename-support-gems" or "main"
21
+ class ParentTaskResolver
22
+ # Default fallback target branch when no parent is found
23
+ DEFAULT_TARGET = "main"
24
+
25
+ # Regex pattern for extracting task number from full task ID
26
+ # Pattern: v.0.9.0+task.202.01 -> captures "202.01"
27
+ TASK_ID_PATTERN = /task\.(\d+(?:\.\d+)?)\z/
28
+
29
+ # SHA pattern for detecting detached HEAD state
30
+ # Git returns 7-40 hex characters for SHAs (7 for short, 40 for full)
31
+ SHA_PATTERN = /\A[0-9a-f]{7,40}\z/i
32
+
33
+ # Initialize a new ParentTaskResolver
34
+ #
35
+ # @param project_root [String] Project root directory
36
+ # @param task_fetcher [TaskFetcher] Optional TaskFetcher instance (for testing)
37
+ def initialize(project_root: Dir.pwd, task_fetcher: nil)
38
+ @project_root = project_root
39
+ @task_fetcher = task_fetcher || TaskFetcher.new
40
+ end
41
+
42
+ # Resolve target branch for a task
43
+ #
44
+ # @param task_data [Hash] Task data hash from ace-task
45
+ # @return [String] Parent's worktree branch or DEFAULT_TARGET
46
+ #
47
+ # @example Subtask with parent
48
+ # resolve_target_branch(subtask_data) # => "202-orchestrator-branch"
49
+ #
50
+ # @example Orchestrator task (no parent)
51
+ # resolve_target_branch(orchestrator_data) # => "main"
52
+ def resolve_target_branch(task_data)
53
+ return DEFAULT_TARGET unless task_data
54
+
55
+ # Extract parent task ID from task ID
56
+ parent_id = extract_parent_id(task_data[:id])
57
+ unless parent_id
58
+ # Non-subtask: use current branch (e.g., feature branch) instead of defaulting to main
59
+ return current_branch_fallback || DEFAULT_TARGET
60
+ end
61
+
62
+ # Load parent task data
63
+ parent_task = load_parent_task(parent_id)
64
+ return DEFAULT_TARGET unless parent_task
65
+
66
+ # Extract parent's worktree branch
67
+ extract_parent_branch(parent_task)
68
+ rescue
69
+ # Silently fall back to default - debugging info available via --verbose flag on CLI
70
+ DEFAULT_TARGET
71
+ end
72
+
73
+ # Load parent task data
74
+ #
75
+ # @param parent_id [String] Parent task ID (e.g., "202")
76
+ # @return [Hash, nil] Parent task data or nil
77
+ def load_parent_task(parent_id)
78
+ return nil unless parent_id
79
+
80
+ @task_fetcher.fetch(parent_id)
81
+ end
82
+
83
+ # Extract parent's worktree branch from task data
84
+ #
85
+ # Implements a 3-level fallback priority chain for determining the target branch:
86
+ #
87
+ # Priority 1: Parent worktree branch
88
+ # - If parent task has worktree metadata with a branch, use that branch
89
+ # - This enables subtasks to target their orchestrator's feature branch
90
+ #
91
+ # Priority 2: Current branch fallback
92
+ # - If parent has no worktree metadata, use the current branch
93
+ # - Skipped if in detached HEAD state (SHA detected)
94
+ # - Allows creating subtask worktrees from the parent's context
95
+ #
96
+ # Priority 3: DEFAULT_TARGET ("main")
97
+ # - Final fallback when no branch context is available
98
+ # - Ensures a valid target branch is always returned
99
+ #
100
+ # @param parent_data [Hash] Parent task data hash
101
+ # @return [String] Parent's worktree branch, current branch, or DEFAULT_TARGET
102
+ #
103
+ # @example Parent with worktree metadata
104
+ # extract_parent_branch({ worktree: { branch: "202-feature" } })
105
+ # # => "202-feature"
106
+ #
107
+ # @example Parent without worktree (uses current branch)
108
+ # # When current branch is "develop"
109
+ # extract_parent_branch({ id: "task.202" })
110
+ # # => "develop"
111
+ #
112
+ # @example Detached HEAD state (falls back to main)
113
+ # # When in detached HEAD state
114
+ # extract_parent_branch({ id: "task.202" })
115
+ # # => "main"
116
+ def extract_parent_branch(parent_data)
117
+ # Try parent worktree branch first
118
+ if parent_data
119
+ worktree_data = parent_data[:worktree] || parent_data["worktree"]
120
+ if worktree_data.is_a?(Hash)
121
+ branch = worktree_data[:branch] || worktree_data["branch"]
122
+ return branch if branch
123
+ end
124
+ end
125
+
126
+ # Fallback: current branch (if valid) or default
127
+ current_branch_fallback || DEFAULT_TARGET
128
+ end
129
+
130
+ private
131
+
132
+ # Extract parent task ID from task ID
133
+ #
134
+ # @param task_id [String] Full task ID (e.g., "v.0.9.0+task.202.01")
135
+ # @return [String, nil] Parent task ID (e.g., "202") or nil
136
+ #
137
+ # @example Subtask
138
+ # extract_parent_id("v.0.9.0+task.202.01") # => "202"
139
+ #
140
+ # @example Orchestrator task
141
+ # extract_parent_id("v.0.9.0+task.202") # => nil
142
+ def extract_parent_id(task_id)
143
+ return nil unless task_id
144
+
145
+ # Extract task number from full ID using TASK_ID_PATTERN
146
+ # Pattern: v.0.9.0+task.202.01 -> 202.01
147
+ match = task_id.match(TASK_ID_PATTERN)
148
+ return nil unless match
149
+
150
+ task_number = match[1]
151
+
152
+ # Check if this is a subtask (contains dot)
153
+ return nil unless task_number.include?(".")
154
+
155
+ # Extract parent number: 202.01 -> 202
156
+ task_number.split(".").first
157
+ end
158
+
159
+ # Get current branch as fallback for target branch
160
+ #
161
+ # Returns the current branch name if available and not a detached HEAD.
162
+ # Used when parent task has no worktree metadata.
163
+ #
164
+ # @return [String, nil] Current branch name or nil if detached/error
165
+ def current_branch_fallback
166
+ branch = Atoms::GitCommand.current_branch
167
+ return nil if branch.nil? || branch.empty?
168
+ return nil if branch == "HEAD"
169
+
170
+ # If branch looks like a SHA, verify it's not actually a branch name
171
+ # This handles edge case of hex-only branch names like "deadbeef"
172
+ if branch.match?(SHA_PATTERN)
173
+ # Check if refs/heads/<name> exists - if so, it's a real branch
174
+ return nil unless Atoms::GitCommand.ref_exists?("refs/heads/#{branch}")
175
+ end
176
+
177
+ branch
178
+ rescue => e
179
+ warn "[DEBUG] current_branch_fallback failed: #{e.message}" if ENV["DEBUG"]
180
+ nil
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Ace
7
+ module Git
8
+ module Worktree
9
+ module Molecules
10
+ # PR creator molecule
11
+ #
12
+ # Creates draft pull requests on GitHub using the gh CLI.
13
+ # Provides a simple interface for creating PRs with graceful degradation
14
+ # when gh CLI is unavailable or not authenticated.
15
+ #
16
+ # @example Create a draft PR
17
+ # creator = PrCreator.new
18
+ # result = creator.create_draft(
19
+ # branch: "125-upstream-setup",
20
+ # base: "main",
21
+ # title: "125 - upstream-setup-and-pr-creation"
22
+ # )
23
+ # result[:pr_number] # => 456
24
+ # result[:pr_url] # => "https://github.com/owner/repo/pull/456"
25
+ #
26
+ # @example Handle unavailable gh CLI
27
+ # creator = PrCreator.new
28
+ # unless creator.gh_available?
29
+ # puts "gh CLI not available"
30
+ # end
31
+ class PrCreator
32
+ # Error raised when gh CLI is not available
33
+ class GhNotAvailableError < StandardError; end
34
+
35
+ # Error raised when gh CLI is not authenticated
36
+ class GhNotAuthenticatedError < StandardError; end
37
+
38
+ # Error raised on network/timeout issues
39
+ class NetworkError < StandardError; end
40
+
41
+ # Error raised when PR already exists
42
+ class PrAlreadyExistsError < StandardError; end
43
+
44
+ # Initialize a new PrCreator
45
+ #
46
+ # @param timeout [Integer] Timeout in seconds (default: 30)
47
+ def initialize(timeout: 30)
48
+ @timeout = timeout
49
+ end
50
+
51
+ # Create a draft pull request
52
+ #
53
+ # @param branch [String] Head branch for the PR
54
+ # @param base [String] Base branch to merge into
55
+ # @param title [String] PR title
56
+ # @param body [String, nil] PR body/description
57
+ # @return [Hash] Result hash with :success, :pr_number, :pr_url, :error
58
+ #
59
+ # @example
60
+ # result = creator.create_draft(branch: "feature", base: "main", title: "Add feature")
61
+ # result[:success] # => true
62
+ # result[:pr_number] # => 123
63
+ # result[:pr_url] # => "https://github.com/owner/repo/pull/123"
64
+ def create_draft(branch:, base:, title:, body: nil)
65
+ # Check gh availability and authentication
66
+ unless gh_available?
67
+ return error_result("gh CLI is not installed")
68
+ end
69
+
70
+ unless gh_authenticated?
71
+ return error_result("gh CLI is not authenticated. Run: gh auth login")
72
+ end
73
+
74
+ # Check if PR already exists for this branch
75
+ existing_pr = find_existing_pr(branch: branch)
76
+ if existing_pr
77
+ return {
78
+ success: true,
79
+ pr_number: existing_pr[:number],
80
+ pr_url: existing_pr[:url],
81
+ existing: true,
82
+ message: "PR already exists for branch"
83
+ }
84
+ end
85
+
86
+ # Build gh command
87
+ cmd = ["gh", "pr", "create", "--draft", "--head", branch, "--base", base, "--title", title]
88
+ cmd += ["--body", body || title]
89
+
90
+ # Execute command
91
+ stdout, stderr, status = execute_with_timeout(cmd, @timeout)
92
+
93
+ if status.success?
94
+ # Parse PR URL from output
95
+ pr_url = stdout.strip
96
+ pr_number = extract_pr_number(pr_url)
97
+
98
+ {
99
+ success: true,
100
+ pr_number: pr_number,
101
+ pr_url: pr_url,
102
+ existing: false,
103
+ error: nil
104
+ }
105
+ else
106
+ handle_creation_error(stderr)
107
+ end
108
+ rescue => e
109
+ error_result("Unexpected error: #{e.message}")
110
+ end
111
+
112
+ # Check if gh CLI is available (cached)
113
+ #
114
+ # @return [Boolean] true if gh is installed and accessible
115
+ def gh_available?
116
+ return @gh_available unless @gh_available.nil?
117
+
118
+ @gh_available = system("which gh > /dev/null 2>&1")
119
+ end
120
+
121
+ # Check if gh CLI is authenticated (cached)
122
+ #
123
+ # @return [Boolean] true if gh is authenticated
124
+ def gh_authenticated?
125
+ return @gh_authenticated unless @gh_authenticated.nil?
126
+
127
+ _, _stderr, status = Open3.capture3("gh", "auth", "status")
128
+ @gh_authenticated = status.success?
129
+ rescue
130
+ @gh_authenticated = false
131
+ end
132
+
133
+ # Find an existing PR for a branch
134
+ #
135
+ # @param branch [String] Branch name to search for
136
+ # @return [Hash, nil] PR info hash or nil if not found
137
+ #
138
+ # @example
139
+ # pr = creator.find_existing_pr(branch: "feature-branch")
140
+ # pr[:number] # => 123
141
+ # pr[:url] # => "https://github.com/owner/repo/pull/123"
142
+ def find_existing_pr(branch:)
143
+ return nil unless gh_available?
144
+
145
+ # Search for open PRs with this head branch
146
+ cmd = [
147
+ "gh", "pr", "list",
148
+ "--head", branch,
149
+ "--state", "open",
150
+ "--json", "number,url",
151
+ "--limit", "1"
152
+ ]
153
+
154
+ stdout, _stderr, status = execute_with_timeout(cmd, @timeout)
155
+ return nil unless status.success?
156
+
157
+ prs = JSON.parse(stdout)
158
+ return nil if prs.empty?
159
+
160
+ pr = prs.first
161
+ {
162
+ number: pr["number"],
163
+ url: pr["url"]
164
+ }
165
+ rescue JSON::ParserError
166
+ nil
167
+ rescue
168
+ nil
169
+ end
170
+
171
+ # Get helpful error message when gh CLI is unavailable
172
+ #
173
+ # @return [String] User-friendly error message with installation guidance
174
+ def gh_not_available_message
175
+ <<~MESSAGE
176
+ gh CLI is required for PR creation but is not installed.
177
+
178
+ Install gh CLI:
179
+ - macOS: brew install gh
180
+ - Linux: See https://github.com/cli/cli#installation
181
+ - Windows: See https://github.com/cli/cli#installation
182
+
183
+ After installation, authenticate with: gh auth login
184
+ MESSAGE
185
+ end
186
+
187
+ private
188
+
189
+ # Execute command with timeout
190
+ #
191
+ # @param cmd [Array<String>] Command and arguments
192
+ # @param timeout [Integer] Timeout in seconds
193
+ # @return [Array<String, String, Process::Status>] stdout, stderr, status
194
+ def execute_with_timeout(cmd, timeout)
195
+ require "timeout"
196
+
197
+ Timeout.timeout(timeout) do
198
+ Open3.capture3(*cmd)
199
+ end
200
+ rescue Timeout::Error
201
+ raise NetworkError, "Request timed out after #{timeout} seconds"
202
+ end
203
+
204
+ # Extract PR number from URL
205
+ #
206
+ # @param url [String] PR URL
207
+ # @return [Integer, nil] PR number or nil if not parseable
208
+ def extract_pr_number(url)
209
+ return nil unless url
210
+
211
+ match = url.match(%r{/pull/(\d+)})
212
+ match ? match[1].to_i : nil
213
+ end
214
+
215
+ # Handle PR creation error
216
+ #
217
+ # @param stderr [String] Error output from gh
218
+ # @return [Hash] Error result hash
219
+ def handle_creation_error(stderr)
220
+ error_msg = stderr.downcase
221
+
222
+ if error_msg.include?("already exists") || error_msg.include?("pull request already exists")
223
+ error_result("A PR already exists for this branch")
224
+ elsif error_msg.include?("authentication") || error_msg.include?("not logged in")
225
+ error_result("GitHub CLI not authenticated. Run: gh auth login")
226
+ elsif error_msg.include?("network") || error_msg.include?("connection")
227
+ error_result("Network error. Check your connection and try again.")
228
+ elsif error_msg.include?("repository not found") || error_msg.include?("not a git repository")
229
+ error_result("Not in a git repository or repository not found on GitHub")
230
+ elsif error_msg.include?("branch") && error_msg.include?("not found")
231
+ error_result("Branch not found on remote. Push the branch first.")
232
+ else
233
+ error_result("GitHub CLI error: #{stderr.strip}")
234
+ end
235
+ end
236
+
237
+ # Create an error result hash
238
+ #
239
+ # @param message [String] Error message
240
+ # @return [Hash] Error result
241
+ def error_result(message)
242
+ {
243
+ success: false,
244
+ pr_number: nil,
245
+ pr_url: nil,
246
+ error: message
247
+ }
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end