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,832 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/task_id_extractor"
4
+
5
+ module Ace
6
+ module Git
7
+ module Worktree
8
+ module Molecules
9
+ # Worktree creator molecule
10
+ #
11
+ # Creates git worktrees with proper validation, naming, and error handling.
12
+ # Integrates with git commands and provides task-aware creation capabilities.
13
+ #
14
+ # @example Create a task-aware worktree
15
+ # creator = WorktreeCreator.new
16
+ # task_data = fetch_task_data("081")
17
+ # config = WorktreeConfig.new
18
+ # result = creator.create_for_task(task_data, config)
19
+ #
20
+ # @example Create a traditional worktree
21
+ # result = creator.create_traditional("feature-branch", "/path/to/worktree")
22
+ class WorktreeCreator
23
+ # Default timeout for git commands
24
+ DEFAULT_TIMEOUT = 60
25
+
26
+ # Initialize a new WorktreeCreator
27
+ #
28
+ # @param config [WorktreeConfig, nil] Worktree configuration
29
+ # @param timeout [Integer] Command timeout in seconds
30
+ def initialize(config: nil, timeout: DEFAULT_TIMEOUT)
31
+ @config = config
32
+ @timeout = timeout
33
+ end
34
+
35
+ # Create a worktree for a specific task
36
+ #
37
+ # @param task_data [Hash] Task data hash from ace-task
38
+ # @param config [WorktreeConfig] Worktree configuration
39
+ # @param counter [Integer, nil] Counter for multiple worktrees of same task
40
+ # @param git_root [String, nil] Git repository root (auto-detected if nil)
41
+ # @param source [String, nil] Git ref to use as start-point for the new branch
42
+ # If nil, uses current branch (default behavior - fixes the branch source bug)
43
+ # @param target_branch [String, nil] PR target branch (for subtasks)
44
+ # @return [Hash] Result with :success, :worktree_path, :branch, :error
45
+ #
46
+ # @example
47
+ # creator = WorktreeCreator.new
48
+ # task_data = fetch_task_data("081")
49
+ # config = ConfigLoader.new.load
50
+ # result = creator.create_for_task(task_data, config)
51
+ # # => { success: true, worktree_path: "/project/.ace-wt/task.081", branch: "081-fix-auth", error: nil }
52
+ #
53
+ # @example With explicit source
54
+ # result = creator.create_for_task(task_data, config, source: "main")
55
+ # # => Creates branch based on 'main' instead of current branch
56
+ #
57
+ # @example Subtask with target branch
58
+ # result = creator.create_for_task(subtask_data, config, target_branch: "202-orchestrator")
59
+ # # => { success: true, target_branch: "202-orchestrator", ... }
60
+ def create_for_task(task_data, config, counter: nil, git_root: nil, source: nil, target_branch: nil)
61
+ return error_result("Task data is required") unless task_data
62
+ return error_result("Configuration is required") unless config
63
+
64
+ begin
65
+ # Determine git repository root
66
+ git_root ||= detect_git_root
67
+ return error_result("Not in a git repository") unless git_root
68
+
69
+ # Generate names based on configuration
70
+ directory_name = config.format_directory(task_data, counter)
71
+ branch_name = config.format_branch(task_data)
72
+
73
+ # Build full path
74
+ worktree_path = File.join(config.absolute_root_path, directory_name)
75
+
76
+ # Ensure parent directory exists before validation
77
+ # (PathExpander rejects paths whose parent doesn't exist yet)
78
+ parent_dir = File.dirname(worktree_path)
79
+ FileUtils.mkdir_p(parent_dir) unless File.exist?(parent_dir)
80
+
81
+ # Validate worktree path
82
+ validation = validate_worktree_path(worktree_path, git_root)
83
+ return error_result(validation[:error]) unless validation[:valid]
84
+
85
+ # Create the worktree with source as start-point
86
+ result = create_worktree(worktree_path, branch_name, git_root, start_point: source)
87
+ return result unless result[:success]
88
+
89
+ # Success - return worktree information
90
+ {
91
+ success: true,
92
+ worktree_path: worktree_path,
93
+ branch: branch_name,
94
+ start_point: result[:start_point],
95
+ directory_name: directory_name,
96
+ task_id: extract_task_id_from_data(task_data),
97
+ git_root: git_root,
98
+ target_branch: target_branch,
99
+ error: nil
100
+ }
101
+ rescue => e
102
+ error_result("Unexpected error: #{e.message}")
103
+ end
104
+ end
105
+
106
+ # Create a worktree for a Pull Request
107
+ #
108
+ # @param pr_data [Hash] PR data hash from PrFetcher
109
+ # @param config [WorktreeConfig] Worktree configuration
110
+ # @param git_root [String, nil] Git repository root (auto-detected if nil)
111
+ # @return [Hash] Result with :success, :worktree_path, :branch, :error
112
+ #
113
+ # @example
114
+ # pr_data = { number: 26, title: "Add feature", head_branch: "feature/auth", base_branch: "main" }
115
+ # result = creator.create_for_pr(pr_data, config)
116
+ # # => { success: true, worktree_path: "/project/.ace-wt/pr-26", branch: "pr-26", tracking: "origin/feature/auth" }
117
+ def create_for_pr(pr_data, config, git_root: nil)
118
+ return error_result("PR data is required") unless pr_data
119
+ return error_result("Configuration is required") unless config
120
+
121
+ begin
122
+ # Determine git repository root
123
+ git_root ||= detect_git_root
124
+ return error_result("Not in a git repository") unless git_root
125
+
126
+ # Get PR-specific configuration (fallback to defaults)
127
+ pr_config = config.pr_config || {}
128
+ remote_name = pr_config[:remote_name] || "origin"
129
+ directory_format = pr_config[:directory_format] || "ace-pr-{number}"
130
+ branch_format = pr_config[:branch_format] || "pr-{number}-{slug}"
131
+
132
+ # Format directory and branch names
133
+ directory_name = format_pr_name(directory_format, pr_data)
134
+ local_branch_name = format_pr_name(branch_format, pr_data)
135
+
136
+ # Build full path
137
+ worktree_path = File.join(config.absolute_root_path, directory_name)
138
+
139
+ # Validate worktree path
140
+ validation = validate_worktree_path(worktree_path, git_root)
141
+ return error_result(validation[:error]) unless validation[:valid]
142
+
143
+ # Fetch the remote branch
144
+ head_branch = pr_data[:head_branch]
145
+ fetch_result = fetch_remote_branch(remote_name, head_branch, git_root)
146
+ return error_result(fetch_result[:error]) unless fetch_result[:success]
147
+
148
+ # Create worktree with remote tracking
149
+ result = create_worktree_with_tracking(
150
+ worktree_path,
151
+ local_branch_name,
152
+ "#{remote_name}/#{head_branch}",
153
+ git_root,
154
+ configure_push: config.configure_push_for_mismatch?
155
+ )
156
+ return result unless result[:success]
157
+
158
+ # Success - return worktree information
159
+ {
160
+ success: true,
161
+ worktree_path: worktree_path,
162
+ branch: local_branch_name,
163
+ tracking: "#{remote_name}/#{head_branch}",
164
+ directory_name: directory_name,
165
+ pr_number: pr_data[:number],
166
+ pr_title: pr_data[:title],
167
+ git_root: git_root,
168
+ error: nil
169
+ }
170
+ rescue => e
171
+ error_result("Unexpected error: #{e.message}")
172
+ end
173
+ end
174
+
175
+ # Create a worktree for a specific branch (local or remote)
176
+ #
177
+ # @param branch_name [String] Branch name (e.g., "feature" or "origin/feature")
178
+ # @param config [WorktreeConfig] Worktree configuration
179
+ # @param git_root [String, nil] Git repository root (auto-detected if nil)
180
+ # @return [Hash] Result with :success, :worktree_path, :branch, :error
181
+ #
182
+ # @example Remote branch
183
+ # result = creator.create_for_branch("origin/feature/auth", config)
184
+ # # => { success: true, worktree_path: "/project/.ace-wt/feature-auth", branch: "feature/auth", tracking: "origin/feature/auth" }
185
+ #
186
+ # @example Local branch
187
+ # result = creator.create_for_branch("local-feature", config)
188
+ # # => { success: true, worktree_path: "/project/.ace-wt/local-feature", branch: "local-feature", tracking: nil }
189
+ def create_for_branch(branch_name, config, git_root: nil)
190
+ return error_result("Branch name is required") if branch_name.nil? || branch_name.empty?
191
+ return error_result("Configuration is required") unless config
192
+
193
+ begin
194
+ # Determine git repository root
195
+ git_root ||= detect_git_root
196
+ return error_result("Not in a git repository") unless git_root
197
+
198
+ # Detect if this is a remote branch
199
+ remote_info = detect_remote_branch(branch_name)
200
+
201
+ if remote_info
202
+ # Remote branch - create with tracking
203
+ create_for_remote_branch(branch_name, remote_info, config, git_root)
204
+ else
205
+ # Local branch - create without tracking
206
+ create_for_local_branch(branch_name, config, git_root)
207
+ end
208
+ rescue => e
209
+ error_result("Unexpected error: #{e.message}")
210
+ end
211
+ end
212
+
213
+ # Create a traditional worktree (not task-aware)
214
+ #
215
+ # @param branch_name [String] Branch name
216
+ # @param worktree_path [String, nil] Worktree path (auto-generated if nil)
217
+ # @param git_root [String, nil] Git repository root (auto-detected if nil)
218
+ # @param source [String, nil] Git ref to use as start-point for the new branch
219
+ # If nil, uses current branch (default behavior)
220
+ # @return [Hash] Result with :success, :worktree_path, :branch, :error
221
+ #
222
+ # @example
223
+ # result = creator.create_traditional("feature-branch", "/tmp/worktree")
224
+ #
225
+ # @example With explicit source
226
+ # result = creator.create_traditional("feature-branch", nil, source: "main")
227
+ def create_traditional(branch_name, worktree_path = nil, git_root: nil, source: nil)
228
+ return error_result("Branch name is required") if branch_name.nil? || branch_name.empty?
229
+
230
+ begin
231
+ # Determine git repository root
232
+ git_root ||= detect_git_root
233
+ return error_result("Not in a git repository") unless git_root
234
+
235
+ # Auto-generate worktree path if not provided
236
+ if worktree_path.nil?
237
+ worktree_path = generate_default_worktree_path(branch_name, git_root)
238
+ end
239
+
240
+ # Validate worktree path
241
+ validation = validate_worktree_path(worktree_path, git_root)
242
+ return error_result(validation[:error]) unless validation[:valid]
243
+
244
+ # Validate branch name
245
+ return error_result("Invalid branch name") unless valid_branch_name?(branch_name)
246
+
247
+ # Check if branch already exists (locally or remotely)
248
+ if branch_exists?(branch_name)
249
+ # Branch exists - create worktree for existing branch
250
+ create_worktree_for_existing_branch(worktree_path, branch_name, git_root)
251
+ else
252
+ # Branch doesn't exist - create new branch with worktree
253
+ create_worktree(worktree_path, branch_name, git_root, start_point: source)
254
+ end
255
+ rescue => e
256
+ error_result("Unexpected error: #{e.message}")
257
+ end
258
+ end
259
+
260
+ # Check if a worktree already exists for the given criteria
261
+ #
262
+ # @param task_data [Hash, nil] Task data hash from ace-task
263
+ # @param branch_name [String, nil] Branch name
264
+ # @param worktree_path [String, nil] Worktree path
265
+ # @return [WorktreeInfo, nil] Existing worktree info or nil
266
+ #
267
+ # @example
268
+ # existing = creator.worktree_exists_for_task?(task_data)
269
+ # existing = creator.worktree_exists_for_branch?("feature-branch")
270
+ def worktree_exists?(task_data: nil, branch_name: nil, worktree_path: nil)
271
+ require_relative "worktree_lister"
272
+ lister = WorktreeLister.new
273
+ worktrees = lister.list_all
274
+
275
+ # Check by task ID
276
+ if task_data
277
+ task_id = extract_task_id_from_data(task_data)
278
+ existing = Models::WorktreeInfo.find_by_task_id(worktrees, task_id)
279
+ return existing if existing
280
+ end
281
+
282
+ # Check by branch name
283
+ if branch_name
284
+ existing = Models::WorktreeInfo.find_by_branch(worktrees, branch_name)
285
+ return existing if existing
286
+ end
287
+
288
+ # Check by path
289
+ if worktree_path
290
+ expanded_path = File.expand_path(worktree_path)
291
+ existing = worktrees.find { |wt| File.expand_path(wt.path) == expanded_path }
292
+ return existing if existing
293
+ end
294
+
295
+ nil
296
+ end
297
+
298
+ # Generate a unique worktree path for a task (handles conflicts)
299
+ #
300
+ # @param task_data [Hash] Task data hash from ace-task
301
+ # @param config [WorktreeConfig] Worktree configuration
302
+ # @param git_root [String] Git repository root
303
+ # @return [String] Unique worktree path
304
+ #
305
+ # @example
306
+ # path = creator.generate_unique_path(task_data, config, git_root)
307
+ # # => "/project/.ace-wt/task.081-2"
308
+ def generate_unique_path(task_data, config, git_root)
309
+ counter = 1
310
+ loop do
311
+ directory_name = config.format_directory(task_data, (counter > 1) ? counter : nil)
312
+ worktree_path = File.join(config.absolute_root_path, directory_name)
313
+
314
+ # Check if path already exists
315
+ existing = worktree_exists?(worktree_path: worktree_path)
316
+ break worktree_path unless existing
317
+
318
+ counter += 1
319
+ end
320
+ end
321
+
322
+ # Validate a worktree path for creation
323
+ #
324
+ # @param worktree_path [String] Path to validate
325
+ # @param git_root [String] Git repository root
326
+ # @return [Hash] Validation result with :valid, :error, :expanded_path
327
+ #
328
+ # @example
329
+ # validation = creator.validate_worktree_path("/tmp/worktree", "/project")
330
+ # # => { valid: true, error: nil, expanded_path: "/tmp/worktree" }
331
+ def validate_worktree_path(worktree_path, git_root)
332
+ require_relative "../atoms/path_expander"
333
+ Atoms::PathExpander.validate_for_worktree(worktree_path, git_root)
334
+ end
335
+
336
+ private
337
+
338
+ # Create a worktree using git commands
339
+ #
340
+ # @param worktree_path [String] Path for the worktree
341
+ # @param branch_name [String] Branch name
342
+ # @param git_root [String] Git repository root
343
+ # @param start_point [String, nil] Git ref to use as start-point for the new branch
344
+ # If nil, uses current branch (or commit SHA if in detached HEAD state)
345
+ # @return [Hash] Result with :success, :worktree_path, :branch, :error
346
+ def create_worktree(worktree_path, branch_name, git_root, start_point: nil)
347
+ require_relative "../atoms/git_command"
348
+
349
+ # Ensure parent directory exists
350
+ parent_dir = File.dirname(worktree_path)
351
+ FileUtils.mkdir_p(parent_dir) unless File.exist?(parent_dir)
352
+
353
+ # Default to current branch if no start_point provided
354
+ # This ensures new branches are based on the current branch, not main worktree HEAD
355
+ start_point ||= Atoms::GitCommand.current_branch
356
+ return error_result("Cannot determine current branch for start-point") unless start_point
357
+
358
+ # Validate start_point exists
359
+ unless Atoms::GitCommand.ref_exists?(start_point)
360
+ return error_result("Source ref '#{start_point}' does not exist")
361
+ end
362
+
363
+ # Create the worktree with explicit start-point
364
+ result = Atoms::GitCommand.worktree("add", worktree_path, "-b", branch_name, start_point, timeout: @timeout)
365
+
366
+ if result[:success]
367
+ {
368
+ success: true,
369
+ worktree_path: worktree_path,
370
+ branch: branch_name,
371
+ start_point: start_point,
372
+ git_root: git_root,
373
+ error: nil
374
+ }
375
+ else
376
+ error_result("Failed to create worktree: #{result[:error]}")
377
+ end
378
+ end
379
+
380
+ # Detect the git repository root
381
+ #
382
+ # @return [String, nil] Git repository root or nil
383
+ def detect_git_root
384
+ require_relative "../atoms/git_command"
385
+ Atoms::GitCommand.git_root
386
+ end
387
+
388
+ # Check if a branch exists locally or as a remote-tracking branch
389
+ #
390
+ # Checks local and remote refs separately since git show-ref --verify
391
+ # requires ALL refs to exist when given multiple refs. This ensures we
392
+ # correctly detect local-only branches (which have no remote tracking ref).
393
+ #
394
+ # @param branch_name [String] Branch name to check
395
+ # @return [Boolean] true if branch exists locally or as origin remote-tracking ref
396
+ def branch_exists?(branch_name)
397
+ require_relative "../atoms/git_command"
398
+
399
+ # Check local branch first (short-circuit if found)
400
+ local_result = Atoms::GitCommand.execute(
401
+ "show-ref", "--verify", "--quiet",
402
+ "refs/heads/#{branch_name}",
403
+ timeout: 5
404
+ )
405
+ return true if local_result[:success]
406
+
407
+ # Check remote tracking branch
408
+ remote_result = Atoms::GitCommand.execute(
409
+ "show-ref", "--verify", "--quiet",
410
+ "refs/remotes/origin/#{branch_name}",
411
+ timeout: 5
412
+ )
413
+ remote_result[:success]
414
+ end
415
+
416
+ # Create a worktree for an existing branch
417
+ #
418
+ # @param worktree_path [String] Path for the worktree
419
+ # @param branch_name [String] Existing branch name
420
+ # @param git_root [String] Git repository root
421
+ # @return [Hash] Result with :success, :worktree_path, :branch, :error
422
+ def create_worktree_for_existing_branch(worktree_path, branch_name, git_root)
423
+ require_relative "../atoms/git_command"
424
+
425
+ # Ensure parent directory exists
426
+ parent_dir = File.dirname(worktree_path)
427
+ FileUtils.mkdir_p(parent_dir) unless File.exist?(parent_dir)
428
+
429
+ # Create the worktree without -b flag (uses existing branch)
430
+ result = Atoms::GitCommand.worktree("add", worktree_path, branch_name, timeout: @timeout)
431
+
432
+ if result[:success]
433
+ {
434
+ success: true,
435
+ worktree_path: worktree_path,
436
+ branch: branch_name,
437
+ start_point: nil,
438
+ git_root: git_root,
439
+ error: nil
440
+ }
441
+ else
442
+ error_result("Failed to create worktree: #{result[:error]}")
443
+ end
444
+ end
445
+
446
+ # Generate a default worktree path based on branch name
447
+ #
448
+ # @param branch_name [String] Branch name
449
+ # @param git_root [String] Git repository root
450
+ # @return [String] Generated worktree path
451
+ def generate_default_worktree_path(branch_name, git_root)
452
+ require_relative "../atoms/slug_generator"
453
+ # Sanitize branch name for directory use
454
+ sanitized_branch = Atoms::SlugGenerator.to_directory_name(branch_name)
455
+
456
+ # Use config's root_path if available, otherwise default to .ace-wt
457
+ if @config
458
+ File.join(@config.absolute_root_path, sanitized_branch)
459
+ else
460
+ File.join(git_root, ".ace-wt", sanitized_branch)
461
+ end
462
+ end
463
+
464
+ # Validate if a branch name is valid for git
465
+ #
466
+ # @param branch_name [String] Branch name to validate
467
+ # @return [Boolean] true if valid
468
+ def valid_branch_name?(branch_name)
469
+ return false if branch_name.nil? || branch_name.empty?
470
+ return false if branch_name.length > 255
471
+
472
+ # Git branch name restrictions (following git's actual rules)
473
+ invalid_patterns = [
474
+ /\.\./, # Cannot contain ..
475
+ /^@{/, # Cannot start with @{
476
+ /\s/, # Cannot contain whitespace
477
+ /[~^:?*\[\]]/, # Cannot contain these special characters
478
+ /\.$/, # Cannot end with .
479
+ /^\.$/, # Cannot be just .
480
+ /\.lock$/, # Cannot end with .lock
481
+ /^$/, # Cannot be empty
482
+ /^\. / # Cannot start with dot followed by space
483
+ ]
484
+
485
+ # Check for invalid patterns
486
+ return false if invalid_patterns.any? { |pattern| branch_name.match?(pattern) }
487
+
488
+ # Cannot be HEAD or other reserved names that conflict with git's internal refs
489
+ reserved_names = %w[HEAD]
490
+ return false if reserved_names.include?(branch_name)
491
+
492
+ # Additional validation: branch name cannot contain sequences that would be invalid
493
+ # in file system paths (since git stores branches as files)
494
+ return false if branch_name.include?(".git")
495
+
496
+ true
497
+ end
498
+
499
+ # Extract task ID from task data
500
+ #
501
+ # @param task_data [Hash] Task data hash from ace-task
502
+ # @return [String] Task ID (e.g., "094")
503
+ def extract_task_id_from_data(task_data)
504
+ # Use shared extractor that preserves subtask IDs (e.g., "121.01")
505
+ Atoms::TaskIDExtractor.extract(task_data)
506
+ end
507
+
508
+ # Create an error result hash
509
+ #
510
+ # @param message [String] Error message
511
+ # @return [Hash] Error result hash
512
+ def error_result(message)
513
+ {
514
+ success: false,
515
+ worktree_path: nil,
516
+ branch: nil,
517
+ error: message
518
+ }
519
+ end
520
+
521
+ # Detect if a branch name refers to a remote branch
522
+ #
523
+ # Only returns a remote/branch hash if the first part is actually a configured
524
+ # git remote. This prevents branches like "feature/login" from being incorrectly
525
+ # treated as remote branches (where "feature" would be the remote).
526
+ #
527
+ # @param branch_name [String] Branch name to check
528
+ # @return [Hash, nil] { remote: "origin", branch: "feature/auth" } or nil if local
529
+ #
530
+ # @example
531
+ # detect_remote_branch("origin/feature/auth")
532
+ # # => { remote: "origin", branch: "feature/auth" }
533
+ #
534
+ # detect_remote_branch("feature/login")
535
+ # # => nil (when "feature" is not a configured remote)
536
+ #
537
+ # detect_remote_branch("local-branch")
538
+ # # => nil
539
+ def detect_remote_branch(branch_name)
540
+ # Check if branch name contains a slash (remote/branch pattern)
541
+ return nil unless branch_name.include?("/")
542
+
543
+ # Split on first slash only
544
+ parts = branch_name.split("/", 2)
545
+ return nil if parts.length != 2
546
+
547
+ potential_remote = parts[0]
548
+ branch = parts[1]
549
+
550
+ # Basic validation
551
+ return nil if potential_remote.empty? || branch.empty?
552
+ # Invalid if branch starts with / or ends with /
553
+ return nil if branch.start_with?("/") || branch.end_with?("/")
554
+ # Invalid if remote ends with / or starts with /
555
+ return nil if potential_remote.start_with?("/") || potential_remote.end_with?("/")
556
+
557
+ # Verify the potential remote is actually configured
558
+ remote_check = validate_remote_exists(potential_remote, Dir.pwd)
559
+ return nil unless remote_check[:exists]
560
+
561
+ {remote: potential_remote, branch: branch}
562
+ end
563
+
564
+ # Validate that a git remote exists
565
+ #
566
+ # @param remote [String] Remote name (e.g., "origin")
567
+ # @param git_root [String] Git repository root
568
+ # @return [Hash] Result with :exists (Boolean) and :remotes (Array) for helpful error messages
569
+ def validate_remote_exists(remote, git_root)
570
+ require_relative "../atoms/git_command"
571
+
572
+ # Get list of remotes
573
+ result = Atoms::GitCommand.execute(
574
+ "remote",
575
+ timeout: 5
576
+ )
577
+
578
+ if result[:success]
579
+ remotes = result[:output].strip.split("\n")
580
+ exists = remotes.include?(remote)
581
+ {exists: exists, remotes: remotes}
582
+ else
583
+ # If we can't list remotes, assume it doesn't exist
584
+ {exists: false, remotes: []}
585
+ end
586
+ end
587
+
588
+ # Fetch a remote branch
589
+ #
590
+ # @param remote [String] Remote name (e.g., "origin")
591
+ # @param branch [String] Branch name
592
+ # @param git_root [String] Git repository root
593
+ # @return [Hash] Result with :success, :error
594
+ def fetch_remote_branch(remote, branch, git_root)
595
+ require_relative "../atoms/git_command"
596
+
597
+ # Validate remote exists first
598
+ validation = validate_remote_exists(remote, git_root)
599
+ unless validation[:exists]
600
+ available = validation[:remotes].empty? ? "no remotes configured" : validation[:remotes].join(", ")
601
+ return {
602
+ success: false,
603
+ error: "Remote '#{remote}' not found. Available remotes: #{available}"
604
+ }
605
+ end
606
+
607
+ result = Atoms::GitCommand.execute(
608
+ "fetch", remote, branch,
609
+ timeout: @timeout
610
+ )
611
+
612
+ if result[:success]
613
+ {success: true, error: nil}
614
+ else
615
+ {success: false, error: "Failed to fetch #{remote}/#{branch}: #{result[:error]}"}
616
+ end
617
+ end
618
+
619
+ # Create a worktree with remote tracking
620
+ #
621
+ # @param worktree_path [String] Path for the worktree
622
+ # @param local_branch_name [String] Local branch name
623
+ # @param remote_branch [String] Remote branch reference (e.g., "origin/feature")
624
+ # @param git_root [String] Git repository root
625
+ # @param configure_push [Boolean] Whether to configure push behavior for branch name mismatches
626
+ # @return [Hash] Result with :success, :worktree_path, :branch, :error
627
+ def create_worktree_with_tracking(worktree_path, local_branch_name, remote_branch, git_root, configure_push: true)
628
+ require_relative "../atoms/git_command"
629
+
630
+ # Ensure parent directory exists
631
+ parent_dir = File.dirname(worktree_path)
632
+ FileUtils.mkdir_p(parent_dir) unless File.exist?(parent_dir)
633
+
634
+ # Create worktree with tracking: git worktree add <path> -b <local> <remote>
635
+ result = Atoms::GitCommand.worktree(
636
+ "add", worktree_path, "-b", local_branch_name, remote_branch,
637
+ timeout: @timeout
638
+ )
639
+
640
+ unless result[:success]
641
+ return error_result("Failed to create worktree: #{result[:error]}")
642
+ end
643
+
644
+ # Configure push behavior if local and remote branch names differ
645
+ if configure_push && local_branch_name != extract_remote_branch_name(remote_branch)
646
+ configure_push_for_worktree(worktree_path, local_branch_name, remote_branch)
647
+ end
648
+
649
+ {
650
+ success: true,
651
+ worktree_path: worktree_path,
652
+ branch: local_branch_name,
653
+ tracking: remote_branch,
654
+ git_root: git_root,
655
+ error: nil
656
+ }
657
+ end
658
+
659
+ # Create a worktree for a remote branch
660
+ #
661
+ # @param branch_name [String] Full branch name (e.g., "origin/feature/auth")
662
+ # @param remote_info [Hash] Remote info from detect_remote_branch
663
+ # @param config [WorktreeConfig] Configuration
664
+ # @param git_root [String] Git repository root
665
+ # @return [Hash] Result hash
666
+ def create_for_remote_branch(branch_name, remote_info, config, git_root)
667
+ remote = remote_info[:remote]
668
+ branch = remote_info[:branch]
669
+
670
+ # Fetch the remote branch
671
+ fetch_result = fetch_remote_branch(remote, branch, git_root)
672
+ return error_result(fetch_result[:error]) unless fetch_result[:success]
673
+
674
+ # Generate local branch name (use full branch path to avoid collisions)
675
+ # For "feature/auth/v1" -> keep as "feature/auth/v1"
676
+ # For "feature/auth" -> keep as "feature/auth"
677
+ local_branch_name = branch
678
+
679
+ # Generate directory name by sanitizing branch for directory use
680
+ # "feature/auth/v1" -> "feature-auth-v1"
681
+ require_relative "../atoms/slug_generator"
682
+ directory_name = Atoms::SlugGenerator.to_directory_name(branch)
683
+
684
+ # Build worktree path
685
+ worktree_path = File.join(config.absolute_root_path, directory_name)
686
+
687
+ # Validate worktree path
688
+ validation = validate_worktree_path(worktree_path, git_root)
689
+ return error_result(validation[:error]) unless validation[:valid]
690
+
691
+ # Create worktree with tracking
692
+ # Check if we should configure push for branch name mismatches
693
+ configure_push = if config.respond_to?(:configure_push_for_mismatch?)
694
+ config.configure_push_for_mismatch?
695
+ else
696
+ # For backward compatibility or when config is not available
697
+ # Default to true for branch creation
698
+ true
699
+ end
700
+
701
+ result = create_worktree_with_tracking(
702
+ worktree_path,
703
+ local_branch_name,
704
+ branch_name,
705
+ git_root,
706
+ configure_push: configure_push
707
+ )
708
+ return result unless result[:success]
709
+
710
+ # Success
711
+ {
712
+ success: true,
713
+ worktree_path: worktree_path,
714
+ branch: local_branch_name,
715
+ tracking: branch_name,
716
+ directory_name: directory_name,
717
+ git_root: git_root,
718
+ error: nil
719
+ }
720
+ end
721
+
722
+ # Create a worktree for a local branch
723
+ #
724
+ # @param branch_name [String] Local branch name
725
+ # @param config [WorktreeConfig] Configuration
726
+ # @param git_root [String] Git repository root
727
+ # @return [Hash] Result hash
728
+ def create_for_local_branch(branch_name, config, git_root)
729
+ # Verify branch exists locally
730
+ require_relative "../atoms/git_command"
731
+ check_result = Atoms::GitCommand.execute(
732
+ "show-ref", "--verify", "--quiet", "refs/heads/#{branch_name}",
733
+ timeout: @timeout
734
+ )
735
+
736
+ unless check_result[:success]
737
+ return error_result("Local branch '#{branch_name}' not found")
738
+ end
739
+
740
+ # Generate directory name
741
+ require_relative "../atoms/slug_generator"
742
+ directory_name = Atoms::SlugGenerator.to_directory_name(branch_name)
743
+
744
+ # Build worktree path
745
+ worktree_path = File.join(config.absolute_root_path, directory_name)
746
+
747
+ # Validate worktree path
748
+ validation = validate_worktree_path(worktree_path, git_root)
749
+ return error_result(validation[:error]) unless validation[:valid]
750
+
751
+ # Create worktree (no tracking for local branches)
752
+ result = create_worktree(worktree_path, branch_name, git_root)
753
+ return result unless result[:success]
754
+
755
+ # Success
756
+ {
757
+ success: true,
758
+ worktree_path: worktree_path,
759
+ branch: branch_name,
760
+ tracking: nil,
761
+ directory_name: directory_name,
762
+ git_root: git_root,
763
+ error: nil
764
+ }
765
+ end
766
+
767
+ # Format PR name using template
768
+ #
769
+ # @param template [String] Template string with {variables}
770
+ # @param pr_data [Hash] PR data hash
771
+ # @return [String] Formatted string
772
+ #
773
+ # @example
774
+ # format_pr_name("pr-{number}-{slug}", { number: 26, title: "Add Feature" })
775
+ # # => "pr-26-add-feature"
776
+ def format_pr_name(template, pr_data)
777
+ require_relative "../atoms/slug_generator"
778
+
779
+ result = template.dup
780
+
781
+ # Replace {number}
782
+ result.gsub!("{number}", pr_data[:number].to_s)
783
+
784
+ # Replace {slug} with slugified title
785
+ if pr_data[:title]
786
+ slug = Atoms::SlugGenerator.from_title(pr_data[:title])
787
+ result.gsub!("{slug}", slug)
788
+ end
789
+
790
+ # Replace {title_slug} (alias for slug)
791
+ if pr_data[:title]
792
+ slug = Atoms::SlugGenerator.from_title(pr_data[:title])
793
+ result.gsub!("{title_slug}", slug)
794
+ end
795
+
796
+ # Replace {base_branch}
797
+ result.gsub!("{base_branch}", pr_data[:base_branch].to_s) if pr_data[:base_branch]
798
+
799
+ result
800
+ end
801
+
802
+ # Extract just the branch name from a remote branch reference
803
+ #
804
+ # @param remote_branch [String] Remote branch reference (e.g., "origin/feature")
805
+ # @return [String] Branch name (e.g., "feature")
806
+ def extract_remote_branch_name(remote_branch)
807
+ remote_info = detect_remote_branch(remote_branch)
808
+ remote_info ? remote_info[:branch] : remote_branch
809
+ end
810
+
811
+ # Configure push behavior for a worktree when local and remote branch names differ
812
+ #
813
+ # @param worktree_path [String] Path to the worktree
814
+ # @param local_branch_name [String] Local branch name
815
+ # @param remote_branch [String] Remote branch reference
816
+ def configure_push_for_worktree(worktree_path, local_branch_name, remote_branch)
817
+ require_relative "../atoms/git_command"
818
+
819
+ # Run git config commands within the worktree
820
+ Dir.chdir(worktree_path) do
821
+ # Set push.default to "upstream" to push to the configured upstream regardless of name
822
+ Atoms::GitCommand.execute("config", "push.default", "upstream", timeout: 5)
823
+
824
+ # Set push.autoSetupRemote to true for convenience
825
+ Atoms::GitCommand.execute("config", "push.autoSetupRemote", "true", timeout: 5)
826
+ end
827
+ end
828
+ end
829
+ end
830
+ end
831
+ end
832
+ end