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.
- checksums.yaml +7 -0
- data/.ace-defaults/git/worktree.yml +250 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git-worktree.yml +19 -0
- data/CHANGELOG.md +957 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +14 -0
- data/docs/demo/ace-git-worktree-getting-started.gif +0 -0
- data/docs/demo/ace-git-worktree-getting-started.tape.yml +28 -0
- data/docs/demo/fixtures/README.md +3 -0
- data/docs/demo/fixtures/sample.txt +1 -0
- data/docs/getting-started.md +114 -0
- data/docs/handbook.md +38 -0
- data/docs/usage.md +334 -0
- data/exe/ace-git-worktree +24 -0
- data/handbook/agents/worktree.ag.md +189 -0
- data/handbook/skills/as-git-worktree/SKILL.md +27 -0
- data/handbook/skills/as-git-worktree-create/SKILL.md +21 -0
- data/handbook/skills/as-git-worktree-manage/SKILL.md +20 -0
- data/handbook/workflow-instructions/git/worktree-create.wf.md +262 -0
- data/handbook/workflow-instructions/git/worktree-manage.wf.md +384 -0
- data/handbook/workflow-instructions/git/worktree.wf.md +224 -0
- data/lib/ace/git/worktree/atoms/git_command.rb +121 -0
- data/lib/ace/git/worktree/atoms/path_expander.rb +189 -0
- data/lib/ace/git/worktree/atoms/slug_generator.rb +235 -0
- data/lib/ace/git/worktree/atoms/task_id_extractor.rb +91 -0
- data/lib/ace/git/worktree/cli/commands/config.rb +50 -0
- data/lib/ace/git/worktree/cli/commands/create.rb +80 -0
- data/lib/ace/git/worktree/cli/commands/list.rb +76 -0
- data/lib/ace/git/worktree/cli/commands/prune.rb +43 -0
- data/lib/ace/git/worktree/cli/commands/remove.rb +48 -0
- data/lib/ace/git/worktree/cli/commands/shared_helpers.rb +66 -0
- data/lib/ace/git/worktree/cli/commands/switch.rb +44 -0
- data/lib/ace/git/worktree/cli.rb +103 -0
- data/lib/ace/git/worktree/commands/config_command.rb +351 -0
- data/lib/ace/git/worktree/commands/create_command.rb +961 -0
- data/lib/ace/git/worktree/commands/list_command.rb +247 -0
- data/lib/ace/git/worktree/commands/prune_command.rb +260 -0
- data/lib/ace/git/worktree/commands/remove_command.rb +522 -0
- data/lib/ace/git/worktree/commands/switch_command.rb +249 -0
- data/lib/ace/git/worktree/configuration.rb +167 -0
- data/lib/ace/git/worktree/models/worktree_config.rb +502 -0
- data/lib/ace/git/worktree/models/worktree_info.rb +303 -0
- data/lib/ace/git/worktree/models/worktree_metadata.rb +294 -0
- data/lib/ace/git/worktree/molecules/config_loader.rb +125 -0
- data/lib/ace/git/worktree/molecules/current_task_linker.rb +136 -0
- data/lib/ace/git/worktree/molecules/hook_executor.rb +361 -0
- data/lib/ace/git/worktree/molecules/parent_task_resolver.rb +186 -0
- data/lib/ace/git/worktree/molecules/pr_creator.rb +253 -0
- data/lib/ace/git/worktree/molecules/task_committer.rb +329 -0
- data/lib/ace/git/worktree/molecules/task_fetcher.rb +244 -0
- data/lib/ace/git/worktree/molecules/task_pusher.rb +183 -0
- data/lib/ace/git/worktree/molecules/task_status_updater.rb +447 -0
- data/lib/ace/git/worktree/molecules/worktree_creator.rb +832 -0
- data/lib/ace/git/worktree/molecules/worktree_lister.rb +337 -0
- data/lib/ace/git/worktree/molecules/worktree_remover.rb +416 -0
- data/lib/ace/git/worktree/organisms/task_worktree_orchestrator.rb +906 -0
- data/lib/ace/git/worktree/organisms/worktree_manager.rb +714 -0
- data/lib/ace/git/worktree/version.rb +9 -0
- data/lib/ace/git/worktree.rb +215 -0
- metadata +218 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Worktree
|
|
6
|
+
module Molecules
|
|
7
|
+
# Task committer molecule
|
|
8
|
+
#
|
|
9
|
+
# Commits task file changes using ace-git-commit or direct git commands.
|
|
10
|
+
# Provides automatic commit message generation and handles commit operations.
|
|
11
|
+
#
|
|
12
|
+
# @example Commit task changes with automatic message
|
|
13
|
+
# committer = TaskCommitter.new
|
|
14
|
+
# success = committer.commit_task_changes(["task.081.md"], "in-progress")
|
|
15
|
+
#
|
|
16
|
+
# @example Commit with custom message
|
|
17
|
+
# success = committer.commit_with_message(["task.081.md"], "Custom commit message")
|
|
18
|
+
class TaskCommitter
|
|
19
|
+
# Fallback timeout for git commands
|
|
20
|
+
# Used only when config is unavailable
|
|
21
|
+
FALLBACK_TIMEOUT = 30
|
|
22
|
+
|
|
23
|
+
# Initialize a new TaskCommitter
|
|
24
|
+
#
|
|
25
|
+
# @param timeout [Integer, nil] Command timeout in seconds (uses config default if nil)
|
|
26
|
+
# @param use_ace_git_commit [Boolean] Whether to use ace-git-commit if available
|
|
27
|
+
def initialize(timeout: nil, use_ace_git_commit: true)
|
|
28
|
+
@timeout = timeout || config_timeout
|
|
29
|
+
@use_ace_git_commit = use_ace_git_commit
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Get timeout from config or fallback
|
|
35
|
+
# @return [Integer] Timeout in seconds
|
|
36
|
+
def config_timeout
|
|
37
|
+
Ace::Git::Worktree.commit_timeout
|
|
38
|
+
rescue
|
|
39
|
+
FALLBACK_TIMEOUT
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
public
|
|
43
|
+
|
|
44
|
+
# Commit task changes with automatic message generation
|
|
45
|
+
#
|
|
46
|
+
# @param files [Array<String>] Files to commit
|
|
47
|
+
# @param status [String] Task status (for message generation)
|
|
48
|
+
# @param task_id [String, nil] Task ID (for message generation)
|
|
49
|
+
# @return [Boolean] true if commit was successful
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# committer = TaskCommitter.new
|
|
53
|
+
# success = committer.commit_task_changes(["task.081.md"], "in-progress", "081")
|
|
54
|
+
def commit_task_changes(files, status, task_id = nil)
|
|
55
|
+
return false if files.nil? || files.empty?
|
|
56
|
+
return false if status.nil? || status.empty?
|
|
57
|
+
|
|
58
|
+
# Generate commit message
|
|
59
|
+
message = generate_commit_message(status, task_id)
|
|
60
|
+
|
|
61
|
+
commit_with_message(files, message)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Commit files with a specific message
|
|
65
|
+
#
|
|
66
|
+
# @param files [Array<String>] Files to commit
|
|
67
|
+
# @param message [String] Commit message
|
|
68
|
+
# @return [Boolean] true if commit was successful
|
|
69
|
+
#
|
|
70
|
+
# @example
|
|
71
|
+
# success = committer.commit_with_message(["task.081.md"], "Update task metadata")
|
|
72
|
+
def commit_with_message(files, message)
|
|
73
|
+
return false if files.nil? || files.empty?
|
|
74
|
+
return false if message.nil? || message.empty?
|
|
75
|
+
|
|
76
|
+
# Filter to only existing files
|
|
77
|
+
existing_files = Array(files).select { |file| File.exist?(file) }
|
|
78
|
+
return false if existing_files.empty?
|
|
79
|
+
|
|
80
|
+
# Try ace-git-commit first if enabled
|
|
81
|
+
if @use_ace_git_commit && ace_git_commit_available?
|
|
82
|
+
return commit_with_ace_git_commit(existing_files, message)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Fallback to direct git commands
|
|
86
|
+
commit_with_git(existing_files, message)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Commit all changes with automatic message
|
|
90
|
+
#
|
|
91
|
+
# @param status [String] Task status
|
|
92
|
+
# @param task_id [String, nil] Task ID
|
|
93
|
+
# @return [Boolean] true if commit was successful
|
|
94
|
+
#
|
|
95
|
+
# @example
|
|
96
|
+
# success = committer.commit_all_changes("in-progress", "081")
|
|
97
|
+
def commit_all_changes(status, task_id = nil)
|
|
98
|
+
# Only attempt commit if there are actually changes to commit
|
|
99
|
+
unless has_uncommitted_changes?
|
|
100
|
+
puts "No changes to commit" if ENV["DEBUG"]
|
|
101
|
+
return true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
message = generate_commit_message(status, task_id)
|
|
105
|
+
commit_all_with_message(message)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Commit all changes with specific message
|
|
109
|
+
#
|
|
110
|
+
# @param message [String] Commit message
|
|
111
|
+
# @return [Boolean] true if commit was successful
|
|
112
|
+
#
|
|
113
|
+
# @example
|
|
114
|
+
# success = committer.commit_all_with_message("Update all task files")
|
|
115
|
+
def commit_all_with_message(message)
|
|
116
|
+
return false if message.nil? || message.empty?
|
|
117
|
+
|
|
118
|
+
# Try ace-git-commit first if enabled
|
|
119
|
+
if @use_ace_git_commit && ace_git_commit_available?
|
|
120
|
+
return commit_all_with_ace_git_commit(message)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Fallback to direct git commands
|
|
124
|
+
commit_all_with_git(message)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Check if there are uncommitted changes
|
|
128
|
+
#
|
|
129
|
+
# @param files [Array<String>, nil] Specific files to check (nil for all)
|
|
130
|
+
# @return [Boolean] true if there are uncommitted changes
|
|
131
|
+
#
|
|
132
|
+
# @example
|
|
133
|
+
# has_changes = committer.has_uncommitted_changes?
|
|
134
|
+
# has_changes = committer.has_uncommitted_changes?(["task.081.md"])
|
|
135
|
+
def has_uncommitted_changes?(files = nil)
|
|
136
|
+
if files.nil? || files.empty?
|
|
137
|
+
# Check all changes
|
|
138
|
+
result = execute_git_command("status", "--porcelain")
|
|
139
|
+
result[:success] && !result[:output].strip.empty?
|
|
140
|
+
else
|
|
141
|
+
# Check specific files
|
|
142
|
+
files.any? do |file|
|
|
143
|
+
next false unless File.exist?(file)
|
|
144
|
+
|
|
145
|
+
result = execute_git_command("diff", "--quiet", file)
|
|
146
|
+
!result[:success]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Get status of files
|
|
152
|
+
#
|
|
153
|
+
# @param files [Array<String>] Files to check
|
|
154
|
+
# @return [Hash] Status information
|
|
155
|
+
#
|
|
156
|
+
# @example
|
|
157
|
+
# status = committer.get_file_status(["task.081.md"])
|
|
158
|
+
# status["task.081.md"] # => "modified"
|
|
159
|
+
def get_file_status(files)
|
|
160
|
+
status = {}
|
|
161
|
+
|
|
162
|
+
Array(files).each do |file|
|
|
163
|
+
next unless File.exist?(file)
|
|
164
|
+
|
|
165
|
+
result = execute_git_command("status", "--porcelain", file)
|
|
166
|
+
if result[:success]
|
|
167
|
+
line = result[:output].strip
|
|
168
|
+
if line.empty?
|
|
169
|
+
status[file] = "unmodified"
|
|
170
|
+
else
|
|
171
|
+
# Parse git status output format
|
|
172
|
+
status_code = line[0, 2]
|
|
173
|
+
status[file] = parse_status_code(status_code)
|
|
174
|
+
end
|
|
175
|
+
else
|
|
176
|
+
status[file] = "error"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
status
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Check if ace-git-commit is available
|
|
184
|
+
#
|
|
185
|
+
# @return [Boolean] true if ace-git-commit command is available
|
|
186
|
+
def ace_git_commit_available?
|
|
187
|
+
return @ace_git_commit_available if defined?(@ace_git_commit_available)
|
|
188
|
+
|
|
189
|
+
result = execute_command("ace-git-commit", "--version", timeout: 5)
|
|
190
|
+
@ace_git_commit_available = result[:success]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
# Generate a commit message based on status and task ID
|
|
196
|
+
#
|
|
197
|
+
# @param status [String] Task status
|
|
198
|
+
# @param task_id [String, nil] Task ID
|
|
199
|
+
# @return [String] Generated commit message
|
|
200
|
+
def generate_commit_message(status, task_id = nil)
|
|
201
|
+
if task_id
|
|
202
|
+
"chore(task-#{task_id}): mark as #{status}"
|
|
203
|
+
else
|
|
204
|
+
"chore(tasks): update task status to #{status}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Commit using ace-git-commit
|
|
209
|
+
#
|
|
210
|
+
# @param files [Array<String>] Files to commit
|
|
211
|
+
# @param message [String] Commit message
|
|
212
|
+
# @return [Boolean] true if commit was successful
|
|
213
|
+
def commit_with_ace_git_commit(files, message)
|
|
214
|
+
result = execute_command("ace-git-commit", "-m", message, *files, timeout: @timeout)
|
|
215
|
+
result[:success]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Commit all changes using ace-git-commit
|
|
219
|
+
#
|
|
220
|
+
# @param message [String] Commit message
|
|
221
|
+
# @return [Boolean] true if commit was successful
|
|
222
|
+
def commit_all_with_ace_git_commit(message)
|
|
223
|
+
result = execute_command("ace-git-commit", "-m", message, timeout: @timeout)
|
|
224
|
+
result[:success]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Commit using direct git commands
|
|
228
|
+
#
|
|
229
|
+
# @param files [Array<String>] Files to commit
|
|
230
|
+
# @param message [String] Commit message
|
|
231
|
+
# @return [Boolean] true if commit was successful
|
|
232
|
+
def commit_with_git(files, message)
|
|
233
|
+
# Stage the files
|
|
234
|
+
add_result = execute_git_command("add", *files)
|
|
235
|
+
return false unless add_result[:success]
|
|
236
|
+
|
|
237
|
+
# Commit
|
|
238
|
+
commit_result = execute_git_command("commit", "-m", message)
|
|
239
|
+
commit_result[:success]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Commit all changes using direct git commands
|
|
243
|
+
#
|
|
244
|
+
# @param message [String] Commit message
|
|
245
|
+
# @return [Boolean] true if commit was successful
|
|
246
|
+
def commit_all_with_git(message)
|
|
247
|
+
# Stage all changes
|
|
248
|
+
add_result = execute_git_command("add", ".")
|
|
249
|
+
return false unless add_result[:success]
|
|
250
|
+
|
|
251
|
+
# Commit
|
|
252
|
+
commit_result = execute_git_command("commit", "-m", message)
|
|
253
|
+
commit_result[:success]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Execute git command using ace-git if available
|
|
257
|
+
#
|
|
258
|
+
# @param args [Array<String>] Command arguments
|
|
259
|
+
# @return [Hash] Result with :success, :output, :error, :exit_code
|
|
260
|
+
def execute_git_command(*args)
|
|
261
|
+
require_relative "../atoms/git_command"
|
|
262
|
+
Atoms::GitCommand.execute(*args, timeout: @timeout)
|
|
263
|
+
rescue LoadError
|
|
264
|
+
# Fallback to direct git execution
|
|
265
|
+
execute_command("git", *args, timeout: @timeout)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Execute a command safely
|
|
269
|
+
#
|
|
270
|
+
# @param command [String] Command to execute
|
|
271
|
+
# @param args [Array<String>] Command arguments
|
|
272
|
+
# @param timeout [Integer] Command timeout
|
|
273
|
+
# @return [Hash] Result with :success, :output, :error, :exit_code
|
|
274
|
+
def execute_command(command, *args, timeout: FALLBACK_TIMEOUT)
|
|
275
|
+
require "open3"
|
|
276
|
+
|
|
277
|
+
full_command = [command] + args
|
|
278
|
+
|
|
279
|
+
stdout, stderr, status = Open3.capture3(*full_command, timeout: timeout)
|
|
280
|
+
|
|
281
|
+
{
|
|
282
|
+
success: status.success?,
|
|
283
|
+
output: stdout.to_s,
|
|
284
|
+
error: stderr.to_s,
|
|
285
|
+
exit_code: status.exitstatus
|
|
286
|
+
}
|
|
287
|
+
rescue Open3::CommandTimeout
|
|
288
|
+
{
|
|
289
|
+
success: false,
|
|
290
|
+
output: "",
|
|
291
|
+
error: "Command timed out after #{timeout} seconds",
|
|
292
|
+
exit_code: 124
|
|
293
|
+
}
|
|
294
|
+
rescue => e
|
|
295
|
+
{
|
|
296
|
+
success: false,
|
|
297
|
+
output: "",
|
|
298
|
+
error: "Command execution failed: #{e.message}",
|
|
299
|
+
exit_code: 1
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Parse git status code to human-readable status
|
|
304
|
+
#
|
|
305
|
+
# @param status_code [String] Two-character status code from git
|
|
306
|
+
# @return [String] Human-readable status
|
|
307
|
+
def parse_status_code(status_code)
|
|
308
|
+
case status_code
|
|
309
|
+
when " M"
|
|
310
|
+
"modified"
|
|
311
|
+
when "A "
|
|
312
|
+
"added"
|
|
313
|
+
when "D "
|
|
314
|
+
"deleted"
|
|
315
|
+
when "R "
|
|
316
|
+
"renamed"
|
|
317
|
+
when "C "
|
|
318
|
+
"copied"
|
|
319
|
+
when "??"
|
|
320
|
+
"untracked"
|
|
321
|
+
else
|
|
322
|
+
"unknown"
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/task_id_extractor"
|
|
4
|
+
|
|
5
|
+
# Try to require ace-task API for direct integration (organism level only)
|
|
6
|
+
begin
|
|
7
|
+
require "ace/task/organisms/task_manager"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
# ace-task not available
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Git
|
|
14
|
+
module Worktree
|
|
15
|
+
module Molecules
|
|
16
|
+
# Task fetcher molecule
|
|
17
|
+
#
|
|
18
|
+
# Fetches task data from ace-task by delegating to its TaskManager.
|
|
19
|
+
# Uses organism-level API which handles all path resolution internally.
|
|
20
|
+
#
|
|
21
|
+
# @example Fetch task data
|
|
22
|
+
# fetcher = TaskFetcher.new
|
|
23
|
+
# task = fetcher.fetch("8pp.t.q7w")
|
|
24
|
+
# task[:title] # => "Fix authentication bug"
|
|
25
|
+
#
|
|
26
|
+
# @example Handle non-existent task
|
|
27
|
+
# task = fetcher.fetch("999")
|
|
28
|
+
# task # => nil
|
|
29
|
+
class TaskFetcher
|
|
30
|
+
# Initialize a new TaskFetcher
|
|
31
|
+
#
|
|
32
|
+
# TaskManager handles all path resolution internally, no configuration needed.
|
|
33
|
+
def initialize
|
|
34
|
+
# TaskManager handles all path resolution internally
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Fetch task data by reference
|
|
38
|
+
#
|
|
39
|
+
# @param task_ref [String] Task reference (e.g., "8pp.t.q7w", "081")
|
|
40
|
+
# @return [Hash, nil] Task data hash or nil if not found
|
|
41
|
+
def fetch(task_ref)
|
|
42
|
+
return nil if task_ref.nil? || task_ref.empty?
|
|
43
|
+
|
|
44
|
+
# Validate basic input for security
|
|
45
|
+
return nil unless valid_task_reference?(task_ref)
|
|
46
|
+
|
|
47
|
+
# Try organism-level API first (preferred)
|
|
48
|
+
if ace_task_available?
|
|
49
|
+
begin
|
|
50
|
+
manager = Ace::Task::Organisms::TaskManager.new
|
|
51
|
+
result = manager.show(task_ref)
|
|
52
|
+
puts "DEBUG: TaskManager result: #{result.inspect}" if ENV["DEBUG"]
|
|
53
|
+
return task_to_hash(result) if result
|
|
54
|
+
rescue => e
|
|
55
|
+
puts "DEBUG: TaskManager exception: #{e.message}" if ENV["DEBUG"]
|
|
56
|
+
puts "DEBUG: Backtrace: #{e.backtrace.first(3).join(", ")}" if ENV["DEBUG"]
|
|
57
|
+
# Fall through to CLI approach
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Fallback to CLI-based approach
|
|
62
|
+
puts "DEBUG: Falling back to CLI for task #{task_ref}" if ENV["DEBUG"]
|
|
63
|
+
fetch_via_cli(task_ref)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if ace-task is available
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] true if ace-task API is available
|
|
69
|
+
def ace_task_available?
|
|
70
|
+
defined?(Ace::Task::Organisms::TaskManager)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get helpful error message when ace-task is unavailable
|
|
74
|
+
#
|
|
75
|
+
# @return [String] User-friendly error message with installation guidance
|
|
76
|
+
def ace_task_unavailable_message
|
|
77
|
+
<<~MESSAGE
|
|
78
|
+
ace-task is not available.
|
|
79
|
+
|
|
80
|
+
Required for task-aware worktree operations.
|
|
81
|
+
|
|
82
|
+
In a mono-repo environment, ensure ace-task is in your Gemfile.
|
|
83
|
+
For standalone installation:
|
|
84
|
+
1. Install ace-task gem: gem install ace-task
|
|
85
|
+
|
|
86
|
+
For more information: https://github.com/cs3b/ace
|
|
87
|
+
MESSAGE
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Convert a Task struct to a hash for backwards compatibility
|
|
93
|
+
#
|
|
94
|
+
# @param task [Ace::Task::Models::Task] Task struct
|
|
95
|
+
# @return [Hash] Task data hash
|
|
96
|
+
def task_to_hash(task)
|
|
97
|
+
{
|
|
98
|
+
id: task.id,
|
|
99
|
+
title: task.title,
|
|
100
|
+
status: task.status,
|
|
101
|
+
path: task.file_path,
|
|
102
|
+
task_number: Atoms::TaskIDExtractor.extract({id: task.id}),
|
|
103
|
+
metadata: task.respond_to?(:metadata) ? (task.metadata || {}) : {}
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Basic validation for task references
|
|
108
|
+
#
|
|
109
|
+
# @param task_ref [String] Task reference to validate
|
|
110
|
+
# @return [Boolean] true if valid
|
|
111
|
+
def valid_task_reference?(task_ref)
|
|
112
|
+
ref = task_ref.to_s.strip
|
|
113
|
+
|
|
114
|
+
# Check for dangerous patterns
|
|
115
|
+
dangerous_patterns = [
|
|
116
|
+
/[;&|`$(){}\[\]]/, # Shell metacharacters
|
|
117
|
+
/\x00/, # Null bytes
|
|
118
|
+
/[\r\n]/, # Newlines
|
|
119
|
+
/[<>]/, # Redirects
|
|
120
|
+
/\.\./ # Directory traversal
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
return false if ref.length > 50
|
|
124
|
+
return false if dangerous_patterns.any? { |pattern| ref.match?(pattern) }
|
|
125
|
+
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Fetch task via CLI (fallback when API fails)
|
|
130
|
+
#
|
|
131
|
+
# @param task_ref [String] Task reference
|
|
132
|
+
# @return [Hash, nil] Task data hash or nil if not found
|
|
133
|
+
def fetch_via_cli(task_ref)
|
|
134
|
+
require "open3"
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
# Use ace-task CLI to get task data (runs in current directory)
|
|
138
|
+
cmd = ["bundle", "exec", "ace-task", "show", task_ref.to_s]
|
|
139
|
+
stdout, _, status = Open3.capture3(*cmd)
|
|
140
|
+
|
|
141
|
+
return nil unless status.success?
|
|
142
|
+
|
|
143
|
+
# Parse CLI output to extract task information
|
|
144
|
+
parse_cli_output(stdout)
|
|
145
|
+
rescue => e
|
|
146
|
+
puts "DEBUG: CLI exception: #{e.message}" if ENV["DEBUG"]
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Parse CLI output to extract task data
|
|
152
|
+
#
|
|
153
|
+
# @param output [String] CLI output from ace-task
|
|
154
|
+
# @return [Hash, nil] Task data hash or nil
|
|
155
|
+
def parse_cli_output(output)
|
|
156
|
+
lines = output.split("\n")
|
|
157
|
+
|
|
158
|
+
# Extract basic task information
|
|
159
|
+
task_data = {
|
|
160
|
+
title: nil,
|
|
161
|
+
status: nil,
|
|
162
|
+
id: nil,
|
|
163
|
+
task_number: nil,
|
|
164
|
+
path: nil,
|
|
165
|
+
metadata: {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
current_section = nil
|
|
169
|
+
content_lines = []
|
|
170
|
+
|
|
171
|
+
lines.each do |line|
|
|
172
|
+
line = line.strip
|
|
173
|
+
|
|
174
|
+
# Parse header information
|
|
175
|
+
if line.start_with?("Task: ")
|
|
176
|
+
task_data[:id] = line.sub(/^Task:\s+/, "")
|
|
177
|
+
elsif line.start_with?("Title: ")
|
|
178
|
+
task_data[:title] = line.sub(/^Title:\s+/, "")
|
|
179
|
+
elsif line.start_with?("Status: ")
|
|
180
|
+
# Extract just the status text (remove emoji)
|
|
181
|
+
status_text = line.sub(/^Status:\s+/, "").gsub(/^[^\w]+\s+/, "")
|
|
182
|
+
task_data[:status] = status_text
|
|
183
|
+
elsif line.start_with?("Priority: ")
|
|
184
|
+
task_data[:metadata]["priority"] = line.sub(/^Priority:\s+/, "")
|
|
185
|
+
elsif line.start_with?("Estimate: ")
|
|
186
|
+
task_data[:metadata]["estimate"] = line.sub(/^Estimate:\s+/, "")
|
|
187
|
+
elsif line.start_with?("Path: ")
|
|
188
|
+
task_data[:path] = line.sub(/^Path:\s+/, "")
|
|
189
|
+
elsif line == "--- Content ---"
|
|
190
|
+
current_section = :content
|
|
191
|
+
next
|
|
192
|
+
elsif current_section == :content
|
|
193
|
+
content_lines << line
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Set content
|
|
198
|
+
task_data[:content] = content_lines.join("\n").strip
|
|
199
|
+
task_data[:metadata]["status"] = task_data[:status] if task_data[:status]
|
|
200
|
+
|
|
201
|
+
# Derive task_number using shared extractor (handles subtasks correctly)
|
|
202
|
+
task_data[:task_number] = Atoms::TaskIDExtractor.extract(task_data)
|
|
203
|
+
|
|
204
|
+
# Validate that we have the minimum required information
|
|
205
|
+
return nil unless task_data[:id] && task_data[:title]
|
|
206
|
+
|
|
207
|
+
task_data
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
public
|
|
212
|
+
|
|
213
|
+
# Get helpful error message when ace-task is unavailable
|
|
214
|
+
#
|
|
215
|
+
# @return [String] User-friendly error message with installation guidance
|
|
216
|
+
def ace_task_unavailable_message
|
|
217
|
+
<<~MESSAGE
|
|
218
|
+
ace-task is not available.
|
|
219
|
+
|
|
220
|
+
Required for task-aware worktree operations.
|
|
221
|
+
|
|
222
|
+
In a mono-repo environment, ensure ace-task is in your Gemfile.
|
|
223
|
+
For standalone installation:
|
|
224
|
+
1. Install ace-task gem: gem install ace-task
|
|
225
|
+
|
|
226
|
+
For more information: https://github.com/cs3b/ace
|
|
227
|
+
MESSAGE
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Check availability and return helpful error if unavailable
|
|
231
|
+
#
|
|
232
|
+
# @return [Hash] { available: boolean, message: string }
|
|
233
|
+
def check_availability_with_message
|
|
234
|
+
if ace_task_available?
|
|
235
|
+
# API is available - this is the preferred method in mono-repo
|
|
236
|
+
{available: true, message: "ace-task API is available"}
|
|
237
|
+
else
|
|
238
|
+
{available: false, message: ace_task_unavailable_message}
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|