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,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