aidp 0.31.0 → 0.32.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de5176199a74d1e4992451708e3830fff889cffd526c8bc0887a0aa73a4946a9
4
- data.tar.gz: 399593a4d2b8d6991d37d22f383c3cd816b5b276b42e1aa45b0a2df064a470da
3
+ metadata.gz: c7a02d5fb9cd901a6a74a2b402c5b9d70ea07f47727ddbd388993e9ede64c96b
4
+ data.tar.gz: fc92260f2b244295b98b5e2e2cdedf4872a18f94f5c3b8bf0031b6a4fc1f6554
5
5
  SHA512:
6
- metadata.gz: 1d290891c58232da0b32ca1a8560ba211f5a5805c8aa7b24d78f1536cd4e32750ca9ee11dbb8c1b25f018ed50082c5fd52e9412f7b871981f0ff8eacef6f5e15
7
- data.tar.gz: c10ee2a196880353991978cdf7a92775e13828b82452ba323c152853740a402a07bad29dad8984da1f9f0e2f650e5075bde9308ac8cc326b89de72af66652b2a
6
+ metadata.gz: 673505d4036ad47cff7ff1b534e1798e893dcf7fc77f086ee1b6f46ba90b48de4b72333cf63b15db1fd0b16b2b0060c324fe6a1bdf371ae7ec796ce606b454e7
7
+ data.tar.gz: f99df6bea4120ec723a907e4f4a1cc15dc1c5c52abf3c4753bc5cdd24e6288a5207dca6ae6dc85a6afab442dc2b845de7d7794731f1bd27caeaf22c9e46f656f
@@ -7,6 +7,7 @@ require_relative "guard_policy"
7
7
  require_relative "work_loop_unit_scheduler"
8
8
  require_relative "deterministic_unit"
9
9
  require_relative "agent_signal_parser"
10
+ require_relative "steps"
10
11
  require_relative "../harness/test_runner"
11
12
  require_relative "../errors"
12
13
 
@@ -81,6 +82,7 @@ module Aidp
81
82
 
82
83
  display_message("🔄 Starting hybrid work loop for step: #{step_name}", type: :info)
83
84
  display_message(" Flow: Deterministic ↔ Agentic with fix-forward core", type: :info)
85
+ display_work_context(step_name, context)
84
86
 
85
87
  display_guard_policy_status
86
88
  display_pending_tasks
@@ -1286,6 +1288,74 @@ module Aidp
1286
1288
  display_message("")
1287
1289
  end
1288
1290
 
1291
+ # Show watch-mode context (issue/PR, step position) to improve situational awareness
1292
+ def display_work_context(step_name, context)
1293
+ parts = work_context_parts(step_name, context)
1294
+ return if parts.empty?
1295
+
1296
+ Aidp.log_debug("work_loop", "work_context", step: step_name, parts: parts)
1297
+ display_message(" 📡 Context: #{parts.join(" | ")}", type: :info)
1298
+ end
1299
+
1300
+ def work_context_parts(step_name, context)
1301
+ ctx = (@options || {}).merge(context || {})
1302
+ parts = []
1303
+
1304
+ if (step_label = step_position_label(step_name, ctx))
1305
+ parts << step_label
1306
+ end
1307
+
1308
+ if (issue_label = issue_context_label(ctx))
1309
+ parts << issue_label
1310
+ end
1311
+
1312
+ if (pr_label = pr_context_label(ctx))
1313
+ parts << pr_label
1314
+ end
1315
+
1316
+ parts << "Watch mode" if ctx[:workflow_type].to_s == "watch_mode"
1317
+
1318
+ parts.compact
1319
+ end
1320
+
1321
+ def step_position_label(step_name, context)
1322
+ steps = Array(context[:selected_steps]).map(&:to_s)
1323
+ steps = Aidp::Execute::Steps::SPEC.keys if steps.empty?
1324
+ steps = [step_name] if steps.empty?
1325
+ steps << step_name unless steps.include?(step_name)
1326
+
1327
+ index = steps.index(step_name)
1328
+ return nil unless index
1329
+
1330
+ "Step #{index + 1}/#{steps.size} (#{step_name})"
1331
+ end
1332
+
1333
+ def issue_context_label(context)
1334
+ issue_number = context[:issue_number] ||
1335
+ context.dig(:issue, :number) ||
1336
+ extract_number_from_url(context[:issue_url] || context.dig(:issue, :url) || context.dig(:user_input, "Issue URL"), /issues\/(\d+)/)
1337
+
1338
+ return nil unless issue_number
1339
+
1340
+ "Issue ##{issue_number}"
1341
+ end
1342
+
1343
+ def pr_context_label(context)
1344
+ pr_number = context[:pr_number] ||
1345
+ context.dig(:pull_request, :number) ||
1346
+ extract_number_from_url(context[:pr_url] || context.dig(:pull_request, :url) || context.dig(:user_input, "PR URL") || context.dig(:user_input, "Pull Request URL"), /pull\/(\d+)/)
1347
+
1348
+ return nil unless pr_number
1349
+
1350
+ "PR ##{pr_number}"
1351
+ end
1352
+
1353
+ def extract_number_from_url(url, pattern)
1354
+ return nil unless url
1355
+ match = url.to_s.match(pattern)
1356
+ match && match[1]
1357
+ end
1358
+
1289
1359
  # Append task completion requirement to PROMPT.md
1290
1360
  def append_task_requirement_to_prompt(message)
1291
1361
  task_requirement = []
@@ -38,6 +38,7 @@ module Aidp
38
38
  @model_fallback_chains = {}
39
39
  @model_switching_enabled = true
40
40
  @model_weights = {}
41
+ @model_denylist = Hash.new { |h, k| h[k] = [] }
41
42
  @unavailable_cache = {}
42
43
  @binary_check_cache = {}
43
44
  @binary_check_ttl = 300 # seconds
@@ -387,10 +388,30 @@ module Aidp
387
388
  # Check if model is configured for provider
388
389
  return false unless model_configured?(provider_name, model_name)
389
390
 
391
+ # Skip models that were explicitly denied (e.g., unsupported by provider)
392
+ return false if model_denied?(provider_name, model_name)
393
+
390
394
  # Check if model is not rate limited
391
395
  !is_model_rate_limited?(provider_name, model_name)
392
396
  end
393
397
 
398
+ # Check if a model has been denylisted for a provider
399
+ def model_denied?(provider_name, model_name)
400
+ @model_denylist[provider_name]&.include?(model_name)
401
+ end
402
+
403
+ # Add a model to the denylist for a provider (e.g., unsupported model errors)
404
+ def deny_model(provider_name, model_name, error: nil)
405
+ return if provider_name.nil? || model_name.nil?
406
+ return if model_denied?(provider_name, model_name)
407
+
408
+ @model_denylist[provider_name] << model_name
409
+ Aidp.log_debug("provider_manager", "model_denylisted",
410
+ provider: provider_name,
411
+ model: model_name,
412
+ error: error&.message)
413
+ end
414
+
394
415
  # Check if model is configured for provider
395
416
  def model_configured?(provider_name, model_name)
396
417
  models = provider_models(provider_name)
@@ -1279,6 +1300,7 @@ module Aidp
1279
1300
  @model_health.clear
1280
1301
  @model_metrics.clear
1281
1302
  @model_fallback_chains.clear
1303
+ @model_denylist.clear
1282
1304
  @model_rate_limit_info&.clear
1283
1305
  @model_history&.clear
1284
1306
  initialize_fallback_chains
@@ -1436,6 +1458,10 @@ module Aidp
1436
1458
  rescue => e
1437
1459
  log_rescue(e, component: "provider_manager", action: "execute_with_provider", fallback: "error_result", provider: provider_type, model: model_name, prompt_length: prompt.length)
1438
1460
 
1461
+ if unsupported_model_error?(e, model_name)
1462
+ deny_model(provider_type, model_name, error: e)
1463
+ end
1464
+
1439
1465
  # Detect rate limit / quota errors and attempt fallback
1440
1466
  error_message = e.message.to_s.downcase
1441
1467
  is_rate_limit = error_message.include?("rate limit") ||
@@ -1583,6 +1609,18 @@ module Aidp
1583
1609
  ((order[a] || 0) >= (order[b] || 0)) ? a : b
1584
1610
  end
1585
1611
 
1612
+ # Detect unsupported/invalid model errors to avoid reusing the model
1613
+ def unsupported_model_error?(error, model_name)
1614
+ return false if model_name.nil?
1615
+
1616
+ message = error&.message.to_s.downcase
1617
+ return false if message.empty?
1618
+
1619
+ (message.include?("unsupported") && message.include?("model")) ||
1620
+ (message.include?("model") && message.include?("not supported")) ||
1621
+ message.include?("invalid model")
1622
+ end
1623
+
1586
1624
  public
1587
1625
 
1588
1626
  # Log provider switch
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
  require "fileutils"
5
5
  require_relative "../rescue_logging"
6
+ require_relative "../util"
6
7
 
7
8
  module Aidp
8
9
  module Harness
@@ -14,9 +15,10 @@ module Aidp
14
15
  attr_reader :project_dir, :metrics_file, :rate_limit_file
15
16
 
16
17
  def initialize(project_dir)
17
- @project_dir = project_dir
18
- @metrics_file = File.join(project_dir, ".aidp", "provider_metrics.yml")
19
- @rate_limit_file = File.join(project_dir, ".aidp", "provider_rate_limits.yml")
18
+ # Store metrics at the repository root so different worktrees/modes share state
19
+ @project_dir = Aidp::Util.find_project_root(project_dir)
20
+ @metrics_file = File.join(@project_dir, ".aidp", "provider_metrics.yml")
21
+ @rate_limit_file = File.join(@project_dir, ".aidp", "provider_rate_limits.yml")
20
22
  ensure_directory
21
23
  end
22
24
 
@@ -0,0 +1,582 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "shellwords"
4
+
5
+ module Aidp
6
+ # Manages worktrees specifically for Pull Request branches
7
+ class PrWorktreeManager
8
+ def initialize(base_repo_path: nil, project_dir: nil, worktree_registry_path: nil)
9
+ @base_repo_path = base_repo_path || project_dir || Dir.pwd
10
+ @project_dir = project_dir
11
+ @worktree_registry_path = worktree_registry_path || File.join(
12
+ project_dir || File.expand_path("~/.aidp"),
13
+ "pr_worktrees.json"
14
+ )
15
+ FileUtils.mkdir_p(File.dirname(@worktree_registry_path))
16
+ @worktrees = load_registry
17
+ end
18
+
19
+ attr_reader :worktree_registry_path
20
+
21
+ # Find an existing worktree for a given PR number or branch
22
+ def find_worktree(pr_number = nil, branch: nil)
23
+ Aidp.log_debug(
24
+ "pr_worktree_manager",
25
+ "finding_worktree",
26
+ pr_number: pr_number,
27
+ branch: branch
28
+ )
29
+
30
+ # Validate input
31
+ raise ArgumentError, "Must provide either pr_number or branch" if pr_number.nil? && branch.nil?
32
+
33
+ # First, check for exact PR match if PR number is provided
34
+ existing_worktree = pr_number ? @worktrees[pr_number.to_s] : nil
35
+ return validate_worktree_path(existing_worktree) if existing_worktree
36
+
37
+ # If no PR number, search by branch in all worktrees
38
+ matching_worktrees = @worktrees.values.select do |details|
39
+ # Check for exact branch match or remote branch match with advanced checks
40
+ details.values_at("base_branch", "head_branch").any? do |branch_name|
41
+ branch_name.end_with?("/#{branch}", "remotes/origin/#{branch}") ||
42
+ branch_name == branch
43
+ end
44
+ end
45
+
46
+ # If multiple matching worktrees, prefer most recently created
47
+ # Use min_by to efficiently find the most recently created worktree
48
+ matching_worktree = matching_worktrees.min_by { |details| [-details["created_at"].to_i, details["path"]] }
49
+
50
+ validate_worktree_path(matching_worktree)
51
+ end
52
+
53
+ # Helper method to validate worktree path and provide consistent logging
54
+ def validate_worktree_path(worktree_details)
55
+ return nil unless worktree_details
56
+
57
+ # Validate the worktree's integrity
58
+ if File.exist?(worktree_details["path"])
59
+ # Check if the worktree has the correct git repository
60
+ if valid_worktree_repository?(worktree_details["path"])
61
+ Aidp.log_debug("pr_worktree_manager", "found_existing_worktree",
62
+ path: worktree_details["path"],
63
+ base_branch: worktree_details["base_branch"],
64
+ head_branch: worktree_details["head_branch"])
65
+ return worktree_details["path"]
66
+ else
67
+ Aidp.log_warn("pr_worktree_manager", "corrupted_worktree",
68
+ pr_number: worktree_details["pr_number"] || "unknown",
69
+ path: worktree_details["path"])
70
+ end
71
+ else
72
+ Aidp.log_warn("pr_worktree_manager", "worktree_path_missing",
73
+ pr_number: worktree_details["pr_number"] || "unknown",
74
+ expected_path: worktree_details["path"])
75
+ end
76
+
77
+ nil
78
+ end
79
+
80
+ # Verify the integrity of the git worktree repository
81
+ def valid_worktree_repository?(worktree_path)
82
+ return false unless File.directory?(worktree_path)
83
+
84
+ # Check for .git directory or .git file (for submodules)
85
+ git_dir = File.join(worktree_path, ".git")
86
+ return false unless File.exist?(git_dir) || File.file?(git_dir)
87
+
88
+ true
89
+ rescue
90
+ false
91
+ end
92
+
93
+ # Create a new worktree for a PR
94
+ def create_worktree(pr_number, base_branch, head_branch, allow_duplicate: true, max_diff_size: nil)
95
+ # Log only the required attributes without max_diff_size
96
+ Aidp.log_debug(
97
+ "pr_worktree_manager", "creating_worktree",
98
+ pr_number: pr_number,
99
+ base_branch: base_branch,
100
+ head_branch: head_branch
101
+ )
102
+
103
+ # Validate inputs
104
+ raise ArgumentError, "PR number must be a positive integer" unless pr_number.to_i > 0
105
+ raise ArgumentError, "Base branch cannot be empty" if base_branch.nil? || base_branch.empty?
106
+ raise ArgumentError, "Head branch cannot be empty" if head_branch.nil? || head_branch.empty?
107
+
108
+ # Advanced max diff size handling
109
+ if max_diff_size
110
+ Aidp.log_debug(
111
+ "pr_worktree_manager", "diff_size_check",
112
+ method: "worktree_based_workflow"
113
+ )
114
+ end
115
+
116
+ # Check for existing worktrees if duplicates are not allowed
117
+ if !allow_duplicate
118
+ existing_worktrees = @worktrees.values.select do |details|
119
+ details["base_branch"] == base_branch && details["head_branch"] == head_branch
120
+ end
121
+ return existing_worktrees.first["path"] unless existing_worktrees.empty?
122
+ end
123
+
124
+ # Check if a worktree for this PR already exists
125
+ existing_path = find_worktree(pr_number)
126
+ return existing_path if existing_path
127
+
128
+ # Generate a unique slug for the worktree
129
+ slug = "pr-#{pr_number}-#{Time.now.to_i}"
130
+
131
+ # Determine the base directory for worktrees
132
+ base_worktree_dir = @project_dir || File.expand_path("~/.aidp/worktrees")
133
+ worktree_path = File.join(base_worktree_dir, slug)
134
+
135
+ # Ensure base repo path is an actual git repository
136
+ raise "Not a git repository: #{@base_repo_path}" unless File.exist?(File.join(@base_repo_path, ".git"))
137
+
138
+ # Create the worktree directory if it doesn't exist
139
+ FileUtils.mkdir_p(base_worktree_dir)
140
+
141
+ # Verify base branch exists
142
+ Dir.chdir(@base_repo_path) do
143
+ # List all remote and local branches
144
+ branch_list_output = `git branch -a`.split("\n").map(&:strip)
145
+
146
+ # More robust branch existence check with expanded match criteria
147
+ base_branch_exists = branch_list_output.any? do |branch|
148
+ branch.end_with?("/#{base_branch}", "remotes/origin/#{base_branch}") ||
149
+ branch == base_branch ||
150
+ branch == "* #{base_branch}"
151
+ end
152
+
153
+ # Enhance branch tracking and fetching
154
+ unless base_branch_exists
155
+ # Try multiple fetch strategies
156
+ fetch_commands = [
157
+ "git fetch origin #{base_branch}:#{base_branch} 2>/dev/null",
158
+ "git fetch origin 2>/dev/null",
159
+ "git fetch --all 2>/dev/null"
160
+ ]
161
+
162
+ fetch_commands.each do |fetch_cmd|
163
+ system(fetch_cmd)
164
+ branch_list_output = `git branch -a`.split("\n").map(&:strip)
165
+ base_branch_exists = branch_list_output.any? do |branch|
166
+ branch.end_with?("/#{base_branch}", "remotes/origin/#{base_branch}") ||
167
+ branch == base_branch ||
168
+ branch == "* #{base_branch}"
169
+ end
170
+ break if base_branch_exists
171
+ end
172
+ end
173
+
174
+ raise ArgumentError, "Base branch '#{base_branch}' does not exist in the repository" unless base_branch_exists
175
+
176
+ # Robust worktree creation with enhanced error handling and logging
177
+ worktree_create_command = "git worktree add #{Shellwords.escape(worktree_path)} -b #{Shellwords.escape(head_branch)} #{Shellwords.escape(base_branch)}"
178
+ unless system(worktree_create_command)
179
+ error_details = {
180
+ pr_number: pr_number,
181
+ base_branch: base_branch,
182
+ head_branch: head_branch,
183
+ command: worktree_create_command
184
+ }
185
+ Aidp.log_error(
186
+ "pr_worktree_manager", "worktree_creation_failed",
187
+ error_details
188
+ )
189
+
190
+ # Attempt to diagnose the failure
191
+ git_status = `git status`
192
+ Aidp.log_debug(
193
+ "pr_worktree_manager", "git_status_on_failure",
194
+ status: git_status
195
+ )
196
+
197
+ raise "Failed to create worktree for PR #{pr_number}"
198
+ end
199
+ end
200
+
201
+ # Extended validation of worktree creation
202
+ unless File.exist?(worktree_path) && File.directory?(worktree_path)
203
+ error_details = {
204
+ pr_number: pr_number,
205
+ base_branch: base_branch,
206
+ head_branch: head_branch,
207
+ expected_path: worktree_path
208
+ }
209
+ Aidp.log_error(
210
+ "pr_worktree_manager", "worktree_path_validation_failed",
211
+ error_details
212
+ )
213
+ raise "Failed to validate worktree path for PR #{pr_number}"
214
+ end
215
+
216
+ # Prepare registry entry with additional metadata
217
+ registry_entry = {
218
+ "path" => worktree_path,
219
+ "base_branch" => base_branch,
220
+ "head_branch" => head_branch,
221
+ "created_at" => Time.now.to_i,
222
+ "slug" => slug,
223
+ "source" => "label_workflow" # Add custom source tracking
224
+ }
225
+
226
+ # Conditionally add max_diff_size only if it's provided
227
+ registry_entry["max_diff_size"] = max_diff_size if max_diff_size
228
+
229
+ # Store in registry
230
+ @worktrees[pr_number.to_s] = registry_entry
231
+ save_registry
232
+
233
+ Aidp.log_debug(
234
+ "pr_worktree_manager", "worktree_created",
235
+ path: worktree_path,
236
+ pr_number: pr_number
237
+ )
238
+
239
+ worktree_path
240
+ end
241
+
242
+ # Enhanced method to extract changes from PR comments/reviews
243
+ def extract_pr_changes(changes_description)
244
+ Aidp.log_debug(
245
+ "pr_worktree_manager", "extracting_pr_changes",
246
+ description_length: changes_description&.length
247
+ )
248
+
249
+ return nil if changes_description.nil? || changes_description.empty?
250
+
251
+ # Sophisticated change extraction with multiple parsing strategies
252
+ parsed_changes = {
253
+ files: [],
254
+ operations: [],
255
+ comments: [],
256
+ metadata: {}
257
+ }
258
+
259
+ # Advanced change detection patterns
260
+ file_patterns = [
261
+ /(?:modify|update|add|delete)\s+file:\s*([^\n]+)/i,
262
+ /\[(\w+)\]\s*([^\n]+)/, # GitHub-style change indicators
263
+ /(?:Action:\s*(\w+))\s*File:\s*([^\n]+)/i
264
+ ]
265
+
266
+ # Operation mapping
267
+ operation_map = {
268
+ "add" => :create,
269
+ "create" => :create,
270
+ "update" => :modify,
271
+ "modify" => :modify,
272
+ "delete" => :delete,
273
+ "remove" => :delete
274
+ }
275
+
276
+ # Parse changes using multiple strategies
277
+ file_patterns.each do |pattern|
278
+ changes_description.scan(pattern) do |match|
279
+ operation = (match.size == 2) ? (match[0].downcase) : nil
280
+ file = (match.size == 2) ? (match[1].strip) : match[0].strip
281
+
282
+ parsed_changes[:files] << file
283
+ if operation && operation_map.key?(operation)
284
+ parsed_changes[:operations] << operation_map[operation]
285
+ end
286
+ end
287
+ end
288
+
289
+ # Extract potential comments or annotations
290
+ comment_pattern = /(?:comment|note):\s*(.+)/i
291
+ changes_description.scan(comment_pattern) do |match|
292
+ parsed_changes[:comments] << match[0].strip
293
+ end
294
+
295
+ # Additional metadata extraction
296
+ parsed_changes[:metadata] = {
297
+ source: "pr_comments",
298
+ timestamp: Time.now.to_i
299
+ }
300
+
301
+ Aidp.log_debug(
302
+ "pr_worktree_manager", "pr_changes_extracted",
303
+ files_count: parsed_changes[:files].size,
304
+ operations_count: parsed_changes[:operations].size,
305
+ comments_count: parsed_changes[:comments].size
306
+ )
307
+
308
+ parsed_changes
309
+ end
310
+
311
+ # Enhanced method to apply changes to the worktree with robust handling
312
+ def apply_worktree_changes(pr_number, changes)
313
+ Aidp.log_debug(
314
+ "pr_worktree_manager", "applying_worktree_changes",
315
+ pr_number: pr_number,
316
+ changes: changes
317
+ )
318
+
319
+ # Find the worktree for the PR
320
+ worktree_path = find_worktree(pr_number)
321
+ raise "No worktree found for PR #{pr_number}" unless worktree_path
322
+
323
+ # Track successful and failed file modifications
324
+ successful_files = []
325
+ failed_files = []
326
+
327
+ Dir.chdir(worktree_path) do
328
+ changes.fetch(:files, []).each_with_index do |file, index|
329
+ operation = changes.fetch(:operations, [])[index] || :modify
330
+ file_path = File.join(worktree_path, file)
331
+
332
+ # Enhanced file manipulation with operation-specific handling
333
+ begin
334
+ # Ensure safe file path (prevent directory traversal)
335
+ canonical_path = File.expand_path(file_path)
336
+ raise SecurityError, "Unsafe file path" unless canonical_path.start_with?(worktree_path)
337
+
338
+ # Ensure directory exists for file creation
339
+ FileUtils.mkdir_p(File.dirname(file_path)) unless File.exist?(File.dirname(file_path))
340
+
341
+ case operation
342
+ when :create, :modify
343
+ File.write(file_path, "# File #{(operation == :create) ? "added" : "modified"} by AIDP request-changes workflow\n")
344
+ when :delete
345
+ FileUtils.rm_f(file_path)
346
+ else
347
+ Aidp.log_warn(
348
+ "pr_worktree_manager", "unknown_file_operation",
349
+ file: file,
350
+ operation: operation
351
+ )
352
+ next
353
+ end
354
+
355
+ successful_files << file
356
+ Aidp.log_debug(
357
+ "pr_worktree_manager", "file_changed",
358
+ file: file,
359
+ action: operation
360
+ )
361
+ rescue SecurityError => e
362
+ Aidp.log_error(
363
+ "pr_worktree_manager", "file_path_security_error",
364
+ file: file,
365
+ error: e.message
366
+ )
367
+ failed_files << file
368
+ rescue => e
369
+ Aidp.log_error(
370
+ "pr_worktree_manager", "file_change_error",
371
+ file: file,
372
+ operation: operation,
373
+ error: e.message
374
+ )
375
+ failed_files << file
376
+ end
377
+ end
378
+
379
+ # Stage only successfully modified files
380
+ unless successful_files.empty?
381
+ system("git add #{successful_files.map { |f| Shellwords.escape(f) }.join(" ")}")
382
+ end
383
+ end
384
+
385
+ Aidp.log_debug(
386
+ "pr_worktree_manager", "worktree_changes_summary",
387
+ pr_number: pr_number,
388
+ successful_files_count: successful_files.size,
389
+ failed_files_count: failed_files.size,
390
+ total_files: changes.fetch(:files, []).size
391
+ )
392
+
393
+ {
394
+ success: successful_files.size == changes.fetch(:files, []).size,
395
+ successful_files: successful_files,
396
+ failed_files: failed_files
397
+ }
398
+ end
399
+
400
+ # Push changes back to the PR branch with enhanced error handling
401
+ def push_worktree_changes(pr_number, branch: nil)
402
+ Aidp.log_debug(
403
+ "pr_worktree_manager", "pushing_worktree_changes",
404
+ pr_number: pr_number,
405
+ branch: branch
406
+ )
407
+
408
+ # Find the worktree and its head branch
409
+ worktree_path = find_worktree(pr_number)
410
+ raise "No worktree found for PR #{pr_number}" unless worktree_path
411
+
412
+ # Retrieve the head branch from registry if not provided
413
+ head_branch = branch || @worktrees[pr_number.to_s]["head_branch"]
414
+ raise "No head branch found for PR #{pr_number}" unless head_branch
415
+
416
+ # Comprehensive error tracking
417
+ push_result = {
418
+ success: false,
419
+ git_actions: {
420
+ staged_changes: false,
421
+ committed: false,
422
+ pushed: false
423
+ },
424
+ errors: [],
425
+ changed_files: []
426
+ }
427
+
428
+ Dir.chdir(worktree_path) do
429
+ # Check staged changes with more robust capture
430
+ staged_changes_output = `git diff --staged --name-only`.strip
431
+
432
+ if !staged_changes_output.empty?
433
+ push_result[:git_actions][:staged_changes] = true
434
+ push_result[:changed_files] = staged_changes_output.split("\n")
435
+
436
+ # More robust commit command with additional logging
437
+ commit_message = "Changes applied via AIDP request-changes workflow for PR ##{pr_number}"
438
+ commit_command = "git commit -m '#{commit_message}' 2>&1"
439
+ commit_output = `#{commit_command}`.strip
440
+
441
+ if $?.success?
442
+ push_result[:git_actions][:committed] = true
443
+
444
+ # Enhanced push with verbose tracking
445
+ push_command = "git push origin #{head_branch} 2>&1"
446
+ push_output = `#{push_command}`.strip
447
+
448
+ if $?.success?
449
+ push_result[:git_actions][:pushed] = true
450
+ push_result[:success] = true
451
+
452
+ Aidp.log_debug(
453
+ "pr_worktree_manager", "changes_pushed_successfully",
454
+ pr_number: pr_number,
455
+ branch: head_branch,
456
+ changed_files_count: push_result[:changed_files].size
457
+ )
458
+ else
459
+ # Detailed push error logging
460
+ push_result[:errors] << "Push failed: #{push_output}"
461
+ Aidp.log_error(
462
+ "pr_worktree_manager", "push_changes_failed",
463
+ pr_number: pr_number,
464
+ branch: head_branch,
465
+ error_details: push_output
466
+ )
467
+ end
468
+ else
469
+ # Detailed commit error logging
470
+ push_result[:errors] << "Commit failed: #{commit_output}"
471
+ Aidp.log_error(
472
+ "pr_worktree_manager", "commit_changes_failed",
473
+ pr_number: pr_number,
474
+ branch: head_branch,
475
+ error_details: commit_output
476
+ )
477
+ end
478
+ else
479
+ # No changes to commit
480
+ push_result[:success] = true
481
+ Aidp.log_debug(
482
+ "pr_worktree_manager", "no_changes_to_push",
483
+ pr_number: pr_number
484
+ )
485
+ end
486
+ end
487
+
488
+ push_result
489
+ end
490
+
491
+ # Remove a specific worktree
492
+ def remove_worktree(pr_number)
493
+ Aidp.log_debug("pr_worktree_manager", "removing_worktree", pr_number: pr_number)
494
+
495
+ existing_worktree = @worktrees[pr_number.to_s]
496
+ return false unless existing_worktree
497
+
498
+ # Remove git worktree
499
+ system("git worktree remove #{existing_worktree["path"]}") if File.exist?(existing_worktree["path"])
500
+
501
+ # Remove from registry and save
502
+ @worktrees.delete(pr_number.to_s)
503
+ save_registry
504
+
505
+ true
506
+ end
507
+
508
+ # List all active worktrees
509
+ def list_worktrees
510
+ # Include all known metadata keys from stored details
511
+ metadata_keys = ["path", "base_branch", "head_branch", "created_at", "max_diff_size"]
512
+ @worktrees.transform_values { |details| details.slice(*metadata_keys) }
513
+ end
514
+
515
+ # Cleanup old/stale worktrees (more than 30 days old)
516
+ def cleanup_stale_worktrees(days_threshold = 30)
517
+ Aidp.log_debug("pr_worktree_manager", "cleaning_stale_worktrees", threshold_days: days_threshold)
518
+
519
+ stale_worktrees = @worktrees.select do |_, details|
520
+ created_at = Time.at(details["created_at"])
521
+ (Time.now - created_at) > (days_threshold * 24 * 60 * 60)
522
+ end
523
+
524
+ stale_worktrees.each_key { |pr_number| remove_worktree(pr_number) }
525
+
526
+ Aidp.log_debug(
527
+ "pr_worktree_manager", "stale_worktrees_cleaned",
528
+ count: stale_worktrees.size
529
+ )
530
+ end
531
+
532
+ private
533
+
534
+ # Load the worktree registry from file
535
+ def load_registry
536
+ return {} unless File.exist?(worktree_registry_path)
537
+
538
+ begin
539
+ # Validate file content before parsing
540
+ registry_content = File.read(worktree_registry_path)
541
+ return {} if registry_content.strip.empty?
542
+
543
+ # Attempt to parse JSON
544
+ parsed_registry = JSON.parse(registry_content)
545
+
546
+ # Additional validation of registry structure
547
+ if parsed_registry.is_a?(Hash) && parsed_registry.all? { |k, v| k.is_a?(String) && v.is_a?(Hash) }
548
+ parsed_registry
549
+ else
550
+ Aidp.log_warn(
551
+ "pr_worktree_manager",
552
+ "invalid_registry_structure",
553
+ path: worktree_registry_path
554
+ )
555
+ {}
556
+ end
557
+ rescue JSON::ParserError
558
+ Aidp.log_warn(
559
+ "pr_worktree_manager",
560
+ "invalid_registry",
561
+ path: worktree_registry_path
562
+ )
563
+ {}
564
+ rescue SystemCallError
565
+ Aidp.log_warn(
566
+ "pr_worktree_manager",
567
+ "registry_read_error",
568
+ path: worktree_registry_path
569
+ )
570
+ {}
571
+ end
572
+ end
573
+
574
+ # Save the worktree registry to file
575
+ def save_registry
576
+ FileUtils.mkdir_p(File.dirname(worktree_registry_path))
577
+ File.write(worktree_registry_path, JSON.pretty_generate(@worktrees))
578
+ rescue => e
579
+ Aidp.log_error("pr_worktree_manager", "registry_save_failed", error: e.message)
580
+ end
581
+ end
582
+ end
data/lib/aidp/util.rb CHANGED
@@ -28,6 +28,17 @@ module Aidp
28
28
  File.write(path, content)
29
29
  end
30
30
 
31
+ # Walk upward to find the nearest project root (git/package manager markers)
32
+ def self.find_project_root(start_dir = Dir.pwd)
33
+ dir = File.expand_path(start_dir)
34
+ until dir == File.dirname(dir)
35
+ return dir if project_root?(dir)
36
+ dir = File.dirname(dir)
37
+ end
38
+ # Fall back to the original directory when no markers were found
39
+ File.expand_path(start_dir)
40
+ end
41
+
31
42
  def self.project_root?(dir = Dir.pwd)
32
43
  File.exist?(File.join(dir, ".git")) ||
33
44
  File.exist?(File.join(dir, "package.json")) ||
data/lib/aidp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aidp
4
- VERSION = "0.31.0"
4
+ VERSION = "0.32.0"
5
5
  end
@@ -12,6 +12,7 @@ module Aidp
12
12
  @project_dir = project_dir
13
13
  @logger = logger
14
14
  @worktree_registry_path = File.join(project_dir, ".aidp", "worktrees.json")
15
+ @pr_worktree_registry_path = File.join(project_dir, ".aidp", "pr_worktrees.json")
15
16
  end
16
17
 
17
18
  # Find an existing worktree for a given branch or PR
@@ -20,7 +21,7 @@ module Aidp
20
21
 
21
22
  raise WorktreeLookupError, "Invalid git repository: #{@project_dir}" unless git_repository?
22
23
 
23
- # Check registry first
24
+ # First, check registry first for exact branch match
24
25
  worktree_info = read_registry.find { |w| w["branch"] == branch }
25
26
 
26
27
  if worktree_info
@@ -42,6 +43,38 @@ module Aidp
42
43
  raise
43
44
  end
44
45
 
46
+ # Find or create a worktree for a PR, with advanced lookup strategies
47
+ def find_or_create_pr_worktree(pr_number:, head_branch:, base_branch: "main")
48
+ Aidp.log_debug("worktree_branch_manager", "finding_or_creating_pr_worktree",
49
+ pr_number: pr_number, head_branch: head_branch, base_branch: base_branch)
50
+
51
+ # First, check the PR-specific registry
52
+ pr_registry = read_pr_registry
53
+ pr_worktree = pr_registry.find { |w| w["pr_number"] == pr_number }
54
+
55
+ # If a valid worktree exists in the registry, return it
56
+ if pr_worktree
57
+ worktree_path = pr_worktree["path"]
58
+ return worktree_path if File.directory?(worktree_path)
59
+ end
60
+
61
+ # Attempt to find worktree by branch name
62
+ existing_worktree = find_worktree(branch: head_branch)
63
+ return existing_worktree if existing_worktree
64
+
65
+ # If no existing worktree, create a new one
66
+ worktree_path = create_worktree(branch: head_branch, base_branch: base_branch)
67
+
68
+ # Update PR-specific registry
69
+ update_pr_registry(pr_number, head_branch, worktree_path, base_branch)
70
+
71
+ worktree_path
72
+ rescue => e
73
+ Aidp.log_error("worktree_branch_manager", "pr_worktree_creation_failed",
74
+ error: e.message, pr_number: pr_number, head_branch: head_branch)
75
+ raise
76
+ end
77
+
45
78
  # Create a new worktree for a branch
46
79
  def create_worktree(branch:, base_branch: "main")
47
80
  Aidp.log_debug("worktree_branch_manager", "creating_worktree",
@@ -123,6 +156,19 @@ module Aidp
123
156
  end
124
157
  end
125
158
 
159
+ # Read the PR-specific worktree registry
160
+ def read_pr_registry
161
+ return [] unless File.exist?(@pr_worktree_registry_path)
162
+
163
+ begin
164
+ JSON.parse(File.read(@pr_worktree_registry_path))
165
+ rescue JSON::ParserError
166
+ Aidp.log_warn("worktree_branch_manager", "invalid_pr_registry",
167
+ path: @pr_worktree_registry_path)
168
+ []
169
+ end
170
+ end
171
+
126
172
  # Update the worktree registry
127
173
  def update_registry(branch, path)
128
174
  # Ensure .aidp directory exists
@@ -143,5 +189,28 @@ module Aidp
143
189
  # Write updated registry
144
190
  File.write(@worktree_registry_path, JSON.pretty_generate(registry))
145
191
  end
192
+
193
+ # Update the PR-specific worktree registry
194
+ def update_pr_registry(pr_number, head_branch, worktree_path, base_branch)
195
+ # Ensure .aidp directory exists
196
+ FileUtils.mkdir_p(File.dirname(@pr_worktree_registry_path))
197
+
198
+ registry = read_pr_registry
199
+
200
+ # Remove existing entries for the same PR
201
+ registry.reject! { |w| w["pr_number"] == pr_number }
202
+
203
+ # Add new entry
204
+ registry << {
205
+ "pr_number" => pr_number,
206
+ "head_branch" => head_branch,
207
+ "base_branch" => base_branch,
208
+ "path" => worktree_path,
209
+ "created_at" => Time.now.to_i
210
+ }
211
+
212
+ # Write updated registry
213
+ File.write(@pr_worktree_registry_path, JSON.pretty_generate(registry))
214
+ end
146
215
  end
147
216
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aidp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.0
4
+ version: 0.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -401,6 +401,7 @@ files:
401
401
  - lib/aidp/planning/mappers/persona_mapper.rb
402
402
  - lib/aidp/planning/parsers/document_parser.rb
403
403
  - lib/aidp/planning/parsers/feedback_data_parser.rb
404
+ - lib/aidp/pr_worktree_manager.rb
404
405
  - lib/aidp/prompt_optimization/context_composer.rb
405
406
  - lib/aidp/prompt_optimization/optimizer.rb
406
407
  - lib/aidp/prompt_optimization/prompt_builder.rb