n2b 0.7.1 → 2.0.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 +4 -4
- data/README.md +291 -118
- data/bin/branch-audit.sh +397 -0
- data/bin/n2b-test-github +22 -0
- data/lib/n2b/base.rb +207 -37
- data/lib/n2b/cli.rb +53 -400
- data/lib/n2b/github_client.rb +391 -0
- data/lib/n2b/jira_client.rb +236 -37
- data/lib/n2b/llm/claude.rb +1 -1
- data/lib/n2b/llm/gemini.rb +1 -1
- data/lib/n2b/llm/open_ai.rb +1 -1
- data/lib/n2b/merge_cli.rb +1771 -136
- data/lib/n2b/message_utils.rb +59 -0
- data/lib/n2b/templates/diff_system_prompt.txt +40 -20
- data/lib/n2b/templates/github_comment.txt +67 -0
- data/lib/n2b/templates/jira_comment.txt +7 -0
- data/lib/n2b/templates/merge_conflict_prompt.txt +2 -2
- data/lib/n2b/version.rb +1 -1
- metadata +8 -3
data/lib/n2b/merge_cli.rb
CHANGED
@@ -1,5 +1,14 @@
|
|
1
1
|
require 'shellwords'
|
2
2
|
require 'rbconfig'
|
3
|
+
require 'optparse'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'stringio'
|
6
|
+
require 'cgi'
|
7
|
+
require_relative 'base'
|
8
|
+
require_relative 'merge_conflict_parser'
|
9
|
+
require_relative 'jira_client'
|
10
|
+
require_relative 'github_client'
|
11
|
+
require_relative 'message_utils'
|
3
12
|
|
4
13
|
module N2B
|
5
14
|
class MergeCLI < Base
|
@@ -17,23 +26,30 @@ module N2B
|
|
17
26
|
def initialize(args)
|
18
27
|
@args = args
|
19
28
|
@options = parse_options
|
20
|
-
@file_path = @args.shift
|
29
|
+
# @file_path = @args.shift # Moved to execute based on mode
|
21
30
|
end
|
22
31
|
|
23
32
|
def execute
|
24
|
-
|
25
|
-
show_usage_and_unresolved
|
26
|
-
exit 1
|
27
|
-
end
|
33
|
+
config = get_config(reconfigure: false, advanced_flow: false)
|
28
34
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
35
|
+
if @options[:analyze]
|
36
|
+
# In analyze mode, @args might be used for custom prompt additions later,
|
37
|
+
# similar to how original cli.rb handled it.
|
38
|
+
# For now, custom_message option is the primary way.
|
39
|
+
handle_diff_analysis(config)
|
40
|
+
else
|
41
|
+
@file_path = @args.shift
|
42
|
+
if @file_path.nil?
|
43
|
+
show_help_and_status # Renamed from show_usage_and_unresolved
|
44
|
+
exit 1
|
45
|
+
end
|
33
46
|
|
34
|
-
|
47
|
+
unless File.exist?(@file_path)
|
48
|
+
puts "File not found: #{@file_path}"
|
49
|
+
exit 1
|
50
|
+
end
|
35
51
|
|
36
|
-
|
52
|
+
parser = MergeConflictParser.new(context_lines: @options[:context_lines])
|
37
53
|
blocks = parser.parse(@file_path)
|
38
54
|
if blocks.empty?
|
39
55
|
puts "No merge conflicts found."
|
@@ -50,7 +66,14 @@ module N2B
|
|
50
66
|
base_content: block.base_content,
|
51
67
|
incoming_content: block.incoming_content,
|
52
68
|
base_label: block.base_label,
|
53
|
-
incoming_label: block.incoming_label
|
69
|
+
incoming_label: block.incoming_label,
|
70
|
+
start_line: block.start_line,
|
71
|
+
end_line: block.end_line,
|
72
|
+
resolved_content: result[:merged_code],
|
73
|
+
llm_suggestion: result[:reason],
|
74
|
+
resolution_method: determine_resolution_method(result),
|
75
|
+
action: determine_action(result),
|
76
|
+
timestamp: Time.now.strftime('%Y-%m-%d %H:%M:%S UTC')
|
54
77
|
})
|
55
78
|
if result[:abort]
|
56
79
|
aborted = true
|
@@ -90,25 +113,783 @@ module N2B
|
|
90
113
|
dir = '.n2b_merge_log'
|
91
114
|
FileUtils.mkdir_p(dir)
|
92
115
|
timestamp = Time.now.strftime('%Y-%m-%d-%H%M%S')
|
93
|
-
log_path = File.join(dir, "#{timestamp}.
|
94
|
-
|
116
|
+
log_path = File.join(dir, "#{timestamp}.html")
|
117
|
+
html_content = generate_merge_log_html(log_entries, timestamp)
|
118
|
+
File.write(log_path, html_content)
|
95
119
|
puts "#{COLOR_GRAY}📝 Merge log saved to #{log_path}#{COLOR_RESET}"
|
96
120
|
end
|
121
|
+
end
|
97
122
|
end
|
98
123
|
|
99
124
|
private
|
100
125
|
|
101
126
|
def parse_options
|
102
|
-
options = {
|
127
|
+
options = {
|
128
|
+
context_lines: MergeConflictParser::DEFAULT_CONTEXT_LINES,
|
129
|
+
analyze: false,
|
130
|
+
branch: nil,
|
131
|
+
jira_ticket: nil,
|
132
|
+
github_issue: nil,
|
133
|
+
requirements_file: nil,
|
134
|
+
update_issue: nil, # nil means ask, true means update, false means no update
|
135
|
+
custom_message: nil
|
136
|
+
}
|
103
137
|
parser = OptionParser.new do |opts|
|
104
|
-
opts.banner =
|
105
|
-
opts.
|
106
|
-
opts.
|
138
|
+
opts.banner = "Usage: n2b-diff FILE [options] OR n2b-diff --analyze [options]"
|
139
|
+
opts.separator ""
|
140
|
+
opts.separator "Merge Conflict Options:"
|
141
|
+
opts.on('--context N', Integer, 'Context lines for merge conflict analysis (default: 10, affects LLM context)') { |v| options[:context_lines] = v }
|
142
|
+
|
143
|
+
opts.separator ""
|
144
|
+
opts.separator "Diff Analysis Options:"
|
145
|
+
opts.on('-a', '--analyze', 'Analyze git/hg diff with AI') { options[:analyze] = true }
|
146
|
+
opts.on('--branch [BRANCH]', 'Branch to compare against for analysis (default: auto-detect main/master)') do |branch|
|
147
|
+
options[:branch] = branch || 'auto' # 'auto' will trigger detection logic
|
148
|
+
end
|
149
|
+
opts.on('-j', '--jira JIRA_ID_OR_URL', 'Jira ticket ID or URL for context or update') do |jira|
|
150
|
+
options[:jira_ticket] = jira
|
151
|
+
end
|
152
|
+
opts.on('--github GITHUB_ISSUE_ID_OR_URL', 'GitHub issue ID or URL for context or update (e.g., owner/repo/issues/123)') do |gh|
|
153
|
+
options[:github_issue] = gh
|
154
|
+
end
|
155
|
+
opts.on('-r', '--requirements FILEPATH', 'Requirements file for diff analysis context') do |file|
|
156
|
+
options[:requirements_file] = file
|
157
|
+
end
|
158
|
+
opts.on('--update', 'Attempt to update the linked Jira/GitHub issue with the analysis result (will ask for confirmation by default)') do
|
159
|
+
options[:update_issue] = true # Explicitly true
|
160
|
+
end
|
161
|
+
opts.on('--no-update', 'Do not attempt to update the linked Jira/GitHub issue') do
|
162
|
+
options[:update_issue] = false # Explicitly false
|
163
|
+
end
|
164
|
+
opts.on('-m', '--message MESSAGE', '--msg MESSAGE', String, 'Custom instructions for AI analysis (max 500 chars)') do |raw_msg|
|
165
|
+
validated_msg = N2B::MessageUtils.validate_message(raw_msg)
|
166
|
+
# Sanitization happens after validation (e.g. truncation)
|
167
|
+
# No need to log here, will log after all options parsed if message exists
|
168
|
+
options[:custom_message] = validated_msg # Store potentially truncated message
|
169
|
+
end
|
170
|
+
|
171
|
+
opts.separator ""
|
172
|
+
opts.separator "Common Options:"
|
173
|
+
opts.on_tail('-h', '--help', 'Show this help message') { puts opts; exit }
|
174
|
+
opts.on_tail('-v', '--version', 'Show version') { puts N2B::VERSION; exit } # Assuming VERSION is defined
|
175
|
+
|
176
|
+
opts.separator ""
|
177
|
+
opts.separator "Examples:"
|
178
|
+
opts.separator " n2b-diff path/to/your/file_with_conflicts.rb"
|
179
|
+
opts.separator " n2b-diff --analyze"
|
180
|
+
opts.separator " n2b-diff --analyze --branch main"
|
181
|
+
opts.separator " n2b-diff --analyze --jira PROJ-123 -m \"Focus on data validation.\""
|
182
|
+
opts.separator " n2b-diff --analyze --github org/repo/issues/42 --update"
|
183
|
+
end
|
184
|
+
|
185
|
+
begin
|
186
|
+
parser.parse!(@args)
|
187
|
+
rescue OptionParser::InvalidOption => e
|
188
|
+
puts "#{COLOR_RED}Error: #{e.message}#{COLOR_RESET}"
|
189
|
+
puts parser.help
|
190
|
+
exit 1
|
191
|
+
end
|
192
|
+
|
193
|
+
# --- Option Validations ---
|
194
|
+
if options[:analyze]
|
195
|
+
# Options that only make sense with analyze
|
196
|
+
else
|
197
|
+
# Options that don't make sense without analyze (if any)
|
198
|
+
if options[:branch] || options[:jira_ticket] || options[:github_issue] || options[:requirements_file] || options[:custom_message] || !options[:update_issue].nil?
|
199
|
+
puts "#{COLOR_RED}Error: Options like --branch, --jira, --github, --requirements, --message, --update/--no-update are only for --analyze mode.#{COLOR_RESET}"
|
200
|
+
puts parser.help
|
201
|
+
exit 1
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
if options[:update_issue] == true && options[:jira_ticket].nil? && options[:github_issue].nil?
|
206
|
+
puts "#{COLOR_RED}Error: --update option requires --jira or --github to be specified for context.#{COLOR_RESET}"
|
207
|
+
puts parser.help
|
208
|
+
exit 1
|
209
|
+
end
|
210
|
+
|
211
|
+
# If --no-update is used without specifying a ticket, it's not an error, just has no effect.
|
212
|
+
# It's fine for --jira or --github to be specified without --update or --no-update (implies ask user)
|
213
|
+
|
214
|
+
# Perform sanitization and logging for custom_message after all options are parsed.
|
215
|
+
if options[:custom_message]
|
216
|
+
# Sanitize the already validated (potentially truncated) message
|
217
|
+
sanitized_message = N2B::MessageUtils.sanitize_message(options[:custom_message])
|
218
|
+
options[:custom_message] = sanitized_message # Store the sanitized version
|
219
|
+
|
220
|
+
# Log the final message that will be used
|
221
|
+
N2B::MessageUtils.log_message("Using custom analysis message: \"#{options[:custom_message]}\"", :info) unless options[:custom_message].strip.empty?
|
107
222
|
end
|
108
|
-
|
223
|
+
|
109
224
|
options
|
110
225
|
end
|
111
226
|
|
227
|
+
# --- Methods moved and adapted from original N2B::CLI for diff analysis ---
|
228
|
+
def handle_diff_analysis(config)
|
229
|
+
vcs_type = get_vcs_type
|
230
|
+
if vcs_type == :none
|
231
|
+
puts "Error: Not a git or hg repository. Diff analysis requires a VCS."
|
232
|
+
exit 1
|
233
|
+
end
|
234
|
+
|
235
|
+
requirements_filepath = @options[:requirements_file] # Adapted option name
|
236
|
+
# user_prompt_addition now directly uses the processed @options[:custom_message]
|
237
|
+
user_prompt_addition = @options[:custom_message] || ""
|
238
|
+
|
239
|
+
# Logging of custom_message is now handled in parse_options by MessageUtils.log_message
|
240
|
+
# So, the specific puts here can be removed or changed to debug level if needed.
|
241
|
+
# For now, removing:
|
242
|
+
# if @options[:custom_message] && !@options[:custom_message].strip.empty?
|
243
|
+
# puts "DEBUG: Custom message for analysis: #{@options[:custom_message]}" # Or use MessageUtils.log_message if preferred for debug
|
244
|
+
# end
|
245
|
+
|
246
|
+
# Ticket / Issue Information - Standardize to use jira_ticket and github_issue
|
247
|
+
# The original cli.rb used @options[:jira_ticket] for both. Here we differentiate.
|
248
|
+
ticket_input = @options[:jira_ticket] || @options[:github_issue]
|
249
|
+
ticket_type = @options[:jira_ticket] ? 'jira' : (@options[:github_issue] ? 'github' : nil)
|
250
|
+
# ticket_update_flag from @options[:update_issue] (true, false, or nil)
|
251
|
+
# nil means ask, true means update, false means no update.
|
252
|
+
ticket_update_flag = @options[:update_issue]
|
253
|
+
|
254
|
+
|
255
|
+
requirements_content = nil
|
256
|
+
|
257
|
+
if ticket_input && ticket_type
|
258
|
+
# Determine which issue tracker based on which option was provided
|
259
|
+
# Default to 'jira' if somehow ticket_type is nil but ticket_input exists (should not happen)
|
260
|
+
tracker_service_name = ticket_type || (config['issue_tracker'] || 'jira')
|
261
|
+
|
262
|
+
case tracker_service_name
|
263
|
+
when 'github'
|
264
|
+
puts "GitHub issue specified: #{ticket_input}"
|
265
|
+
if config['github'] && config['github']['repo'] && config['github']['access_token']
|
266
|
+
begin
|
267
|
+
github_client = N2B::GitHubClient.new(config) # Ensure N2B::GitHubClient is available
|
268
|
+
puts "Fetching GitHub issue details..."
|
269
|
+
requirements_content = github_client.fetch_issue(ticket_input) # ticket_input here is ID/URL
|
270
|
+
puts "Successfully fetched GitHub issue details."
|
271
|
+
rescue StandardError => e
|
272
|
+
puts "Error fetching GitHub issue: #{e.message}"
|
273
|
+
puts "Proceeding with diff analysis without GitHub issue details."
|
274
|
+
end
|
275
|
+
else
|
276
|
+
puts "GitHub configuration is missing or incomplete in N2B settings."
|
277
|
+
puts "Please configure GitHub using 'n2b -c' (or main n2b config) to fetch issue details."
|
278
|
+
puts "Proceeding with diff analysis without GitHub issue details."
|
279
|
+
end
|
280
|
+
when 'jira'
|
281
|
+
puts "Jira ticket specified: #{ticket_input}"
|
282
|
+
if config['jira'] && config['jira']['domain'] && config['jira']['email'] && config['jira']['api_key']
|
283
|
+
begin
|
284
|
+
jira_client = N2B::JiraClient.new(config) # Ensure N2B::JiraClient is available
|
285
|
+
puts "Fetching Jira ticket details..."
|
286
|
+
requirements_content = jira_client.fetch_ticket(ticket_input)
|
287
|
+
puts "Successfully fetched Jira ticket details."
|
288
|
+
rescue N2B::JiraClient::JiraApiError => e
|
289
|
+
puts "Error fetching Jira ticket: #{e.message}"
|
290
|
+
puts "Proceeding with diff analysis without Jira ticket details."
|
291
|
+
rescue ArgumentError => e # Catches config errors from JiraClient init
|
292
|
+
puts "Jira configuration error: #{e.message}"
|
293
|
+
puts "Please ensure Jira is configured correctly."
|
294
|
+
puts "Proceeding with diff analysis without Jira ticket details."
|
295
|
+
rescue StandardError => e
|
296
|
+
puts "An unexpected error occurred while fetching Jira ticket: #{e.message}"
|
297
|
+
puts "Proceeding with diff analysis without Jira ticket details."
|
298
|
+
end
|
299
|
+
else
|
300
|
+
puts "Jira configuration is missing or incomplete in N2B settings."
|
301
|
+
puts "Please configure Jira using 'n2b -c' (or main n2b config) to fetch ticket details."
|
302
|
+
puts "Proceeding with diff analysis without Jira ticket details."
|
303
|
+
end
|
304
|
+
end
|
305
|
+
# Common message for ticket update status based on new flag
|
306
|
+
if ticket_update_flag == true
|
307
|
+
puts "Note: Issue update is flagged (--update)."
|
308
|
+
elsif ticket_update_flag == false
|
309
|
+
puts "Note: Issue will not be updated (--no-update)."
|
310
|
+
else # nil case
|
311
|
+
puts "Note: You will be prompted whether to update the issue after analysis."
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
|
316
|
+
if requirements_content.nil? && requirements_filepath
|
317
|
+
if File.exist?(requirements_filepath)
|
318
|
+
puts "Loading requirements from file: #{requirements_filepath}"
|
319
|
+
requirements_content = File.read(requirements_filepath)
|
320
|
+
else
|
321
|
+
puts "Error: Requirements file not found: #{requirements_filepath}"
|
322
|
+
puts "Proceeding with diff analysis without file-based requirements."
|
323
|
+
end
|
324
|
+
elsif requirements_content && requirements_filepath
|
325
|
+
puts "Note: Both issue details and a requirements file were provided. Using issue details for analysis context."
|
326
|
+
end
|
327
|
+
|
328
|
+
diff_output = execute_vcs_diff(vcs_type, @options[:branch])
|
329
|
+
if diff_output.nil? || diff_output.strip.empty?
|
330
|
+
puts "No differences found to analyze."
|
331
|
+
exit 0
|
332
|
+
end
|
333
|
+
|
334
|
+
# Pass user_prompt_addition (custom_message) to analyze_diff
|
335
|
+
analysis_result = analyze_diff(diff_output, config, user_prompt_addition, requirements_content)
|
336
|
+
|
337
|
+
# --- Ticket Update Logic (adapted) ---
|
338
|
+
if ticket_input && ticket_type && analysis_result && !analysis_result.empty?
|
339
|
+
tracker_service_name = ticket_type
|
340
|
+
|
341
|
+
# Determine if we should proceed with update based on ticket_update_flag
|
342
|
+
proceed_with_update_action = false
|
343
|
+
if ticket_update_flag == true # --update
|
344
|
+
proceed_with_update_action = true
|
345
|
+
elsif ticket_update_flag.nil? # Ask user
|
346
|
+
puts "\nWould you like to update #{tracker_service_name.capitalize} issue #{ticket_input} with this analysis? (y/n)"
|
347
|
+
user_choice = $stdin.gets.chomp.downcase
|
348
|
+
proceed_with_update_action = user_choice == 'y'
|
349
|
+
end # If ticket_update_flag is false, proceed_with_update_action remains false
|
350
|
+
|
351
|
+
if proceed_with_update_action
|
352
|
+
case tracker_service_name
|
353
|
+
when 'github'
|
354
|
+
if config['github'] && config['github']['repo'] && config['github']['access_token']
|
355
|
+
# Pass custom_message to formatting function
|
356
|
+
comment_data = format_analysis_for_github(analysis_result, @options[:custom_message])
|
357
|
+
begin
|
358
|
+
update_client = N2B::GitHubClient.new(config)
|
359
|
+
puts "Updating GitHub issue #{ticket_input}..."
|
360
|
+
if update_client.update_issue(ticket_input, comment_data) # ticket_input is ID/URL
|
361
|
+
puts "GitHub issue #{ticket_input} updated successfully."
|
362
|
+
else
|
363
|
+
puts "Failed to update GitHub issue #{ticket_input}."
|
364
|
+
end
|
365
|
+
rescue StandardError => e
|
366
|
+
puts "Error updating GitHub issue: #{e.message}"
|
367
|
+
end
|
368
|
+
else
|
369
|
+
puts "GitHub configuration is missing. Cannot update GitHub issue."
|
370
|
+
end
|
371
|
+
when 'jira'
|
372
|
+
if config['jira'] && config['jira']['domain'] && config['jira']['email'] && config['jira']['api_key']
|
373
|
+
# Pass custom_message to formatting function
|
374
|
+
jira_comment_data = format_analysis_for_jira(analysis_result, @options[:custom_message])
|
375
|
+
begin
|
376
|
+
update_jira_client = N2B::JiraClient.new(config)
|
377
|
+
puts "Updating Jira ticket #{ticket_input}..."
|
378
|
+
if update_jira_client.update_ticket(ticket_input, jira_comment_data)
|
379
|
+
puts "Jira ticket #{ticket_input} updated successfully."
|
380
|
+
else
|
381
|
+
puts "Failed to update Jira ticket #{ticket_input}."
|
382
|
+
end
|
383
|
+
rescue N2B::JiraClient::JiraApiError => e
|
384
|
+
puts "Error updating Jira ticket: #{e.message}"
|
385
|
+
rescue ArgumentError => e
|
386
|
+
puts "Jira configuration error for update: #{e.message}"
|
387
|
+
rescue StandardError => e
|
388
|
+
puts "An unexpected error occurred while updating Jira ticket: #{e.message}"
|
389
|
+
end
|
390
|
+
else
|
391
|
+
puts "Jira configuration is missing. Cannot update Jira ticket."
|
392
|
+
end
|
393
|
+
end
|
394
|
+
else
|
395
|
+
puts "Issue/Ticket update skipped."
|
396
|
+
end
|
397
|
+
elsif ticket_input && (analysis_result.nil? || analysis_result.empty?)
|
398
|
+
puts "Skipping ticket update as analysis result was empty or not generated."
|
399
|
+
end
|
400
|
+
# --- End of Ticket Update Logic ---
|
401
|
+
analysis_result # Return for potential further use or testing
|
402
|
+
end
|
403
|
+
|
404
|
+
def get_vcs_type
|
405
|
+
if Dir.exist?(File.join(Dir.pwd, '.git'))
|
406
|
+
:git
|
407
|
+
elsif Dir.exist?(File.join(Dir.pwd, '.hg'))
|
408
|
+
:hg
|
409
|
+
else
|
410
|
+
:none
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
def execute_vcs_diff(vcs_type, branch_option = nil)
|
415
|
+
# Ensure shell commands are properly escaped if branch_option comes from user input
|
416
|
+
# Though OptionParser should handle this for fixed options.
|
417
|
+
# For this method, branch_option is generally safe as it's from parsed options.
|
418
|
+
|
419
|
+
case vcs_type
|
420
|
+
when :git
|
421
|
+
target_branch_name = branch_option == 'auto' ? detect_git_default_branch : branch_option
|
422
|
+
if target_branch_name
|
423
|
+
unless validate_git_branch_exists(target_branch_name)
|
424
|
+
puts "Error: Git branch '#{target_branch_name}' does not exist or is not accessible."
|
425
|
+
# Suggest available branches if possible (might be noisy, consider a debug flag)
|
426
|
+
# puts "Available local branches:\n`git branch`"
|
427
|
+
# puts "Available remote branches for 'origin':\n`git branch -r`"
|
428
|
+
exit 1
|
429
|
+
end
|
430
|
+
puts "Comparing current HEAD against git branch '#{target_branch_name}'..."
|
431
|
+
# Use three dots for "changes on your branch since target_branch_name diverged from it"
|
432
|
+
# Or two dots for "changes between target_branch_name and your current HEAD"
|
433
|
+
# Three dots is common for PR-like diffs.
|
434
|
+
`git diff #{Shellwords.escape(target_branch_name)}...HEAD`
|
435
|
+
else
|
436
|
+
puts "Could not automatically detect default git branch and no branch specified. Falling back to 'git diff HEAD' (staged changes)..."
|
437
|
+
`git diff HEAD` # This shows staged changes. `git diff` shows unstaged.
|
438
|
+
end
|
439
|
+
when :hg
|
440
|
+
target_branch_name = branch_option == 'auto' ? detect_hg_default_branch : branch_option
|
441
|
+
if target_branch_name
|
442
|
+
unless validate_hg_branch_exists(target_branch_name)
|
443
|
+
puts "Error: Mercurial branch '#{target_branch_name}' does not exist."
|
444
|
+
exit 1
|
445
|
+
end
|
446
|
+
puts "Comparing current working directory against hg branch '#{target_branch_name}'..."
|
447
|
+
# For hg, diff against the revision of the branch tip
|
448
|
+
`hg diff -r #{Shellwords.escape(target_branch_name)}`
|
449
|
+
else
|
450
|
+
puts "Could not automatically detect default hg branch and no branch specified. Falling back to 'hg diff' (uncommitted changes)..."
|
451
|
+
`hg diff` # Shows uncommitted changes.
|
452
|
+
end
|
453
|
+
else
|
454
|
+
puts "Error: Unsupported VCS type." # Should be caught by get_vcs_type check earlier
|
455
|
+
""
|
456
|
+
end
|
457
|
+
end
|
458
|
+
|
459
|
+
def detect_git_default_branch
|
460
|
+
# Method 1: Check origin/HEAD symbolic ref (most reliable for remotes)
|
461
|
+
result = `git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null`.strip
|
462
|
+
return result.split('/').last if $?.success? && !result.empty?
|
463
|
+
|
464
|
+
# Method 2: Check local 'main' or 'master' if they track a remote branch
|
465
|
+
['main', 'master'].each do |branch|
|
466
|
+
local_tracking = `git rev-parse --abbrev-ref #{branch}@{upstream} 2>/dev/null`.strip
|
467
|
+
return branch if $?.success? && !local_tracking.empty?
|
468
|
+
end
|
469
|
+
|
470
|
+
# Method 3: Check remote show origin for HEAD branch (less direct but good fallback)
|
471
|
+
result = `git remote show origin 2>/dev/null | grep "HEAD branch"`.strip
|
472
|
+
if $?.success? && !result.empty?
|
473
|
+
match = result.match(/HEAD branch:\s*(\S+)/)
|
474
|
+
return match[1] if match && match[1] != '(unknown)'
|
475
|
+
end
|
476
|
+
|
477
|
+
# Method 4: Last resort, check for local main/master even if not tracking
|
478
|
+
['main', 'master'].each do |branch|
|
479
|
+
return branch if system("git show-ref --verify --quiet refs/heads/#{branch}")
|
480
|
+
end
|
481
|
+
nil # No default branch found
|
482
|
+
end
|
483
|
+
|
484
|
+
def detect_hg_default_branch
|
485
|
+
# Mercurial typically uses 'default' as the main branch.
|
486
|
+
# `hg branch` shows the current branch. If it's 'default', that's a strong candidate.
|
487
|
+
current_branch = `hg branch 2>/dev/null`.strip
|
488
|
+
return current_branch if $?.success? && current_branch == 'default'
|
489
|
+
|
490
|
+
# Check if 'default' branch exists in the list of branches
|
491
|
+
branches_output = `hg branches 2>/dev/null`
|
492
|
+
if $?.success? && branches_output.lines.any? { |line| line.strip.start_with?('default ') }
|
493
|
+
return 'default'
|
494
|
+
end
|
495
|
+
|
496
|
+
# Fallback if 'default' isn't found but there's only one branch (common in simple repos)
|
497
|
+
if $?.success?
|
498
|
+
active_branches = branches_output.lines.reject { |line| line.include?('(inactive)') }
|
499
|
+
if active_branches.size == 1
|
500
|
+
return active_branches.first.split.first
|
501
|
+
end
|
502
|
+
end
|
503
|
+
|
504
|
+
# If current branch is not 'default' but exists, it might be the one being used as main.
|
505
|
+
return current_branch if $?.success? && !current_branch.empty?
|
506
|
+
|
507
|
+
'default' # Default assumption for Mercurial
|
508
|
+
end
|
509
|
+
|
510
|
+
def validate_git_branch_exists(branch)
|
511
|
+
# Check local branches
|
512
|
+
return true if system("git show-ref --verify --quiet refs/heads/#{Shellwords.escape(branch)}")
|
513
|
+
# Check remote branches (assuming 'origin' remote)
|
514
|
+
return true if system("git show-ref --verify --quiet refs/remotes/origin/#{Shellwords.escape(branch)}")
|
515
|
+
false
|
516
|
+
end
|
517
|
+
|
518
|
+
def validate_hg_branch_exists(branch)
|
519
|
+
# `hg branches` lists all branches. Check if the branch is in the list.
|
520
|
+
# `hg id -r branchname` would fail if branch doesn't exist.
|
521
|
+
system("hg id -r #{Shellwords.escape(branch)} > /dev/null 2>&1")
|
522
|
+
end
|
523
|
+
|
524
|
+
def build_diff_analysis_prompt(diff_output, user_prompt_addition = "", requirements_content = nil, config = {})
|
525
|
+
default_system_prompt_path = resolve_template_path('diff_system_prompt', config) # Ensure this template exists
|
526
|
+
default_system_prompt = File.read(default_system_prompt_path).strip rescue "Analyze this diff." # Fallback
|
527
|
+
|
528
|
+
# user_prompt_addition is @options[:custom_message], already validated and sanitized.
|
529
|
+
user_instructions_section = ""
|
530
|
+
if user_prompt_addition && !user_prompt_addition.to_s.strip.empty?
|
531
|
+
# No need to call .strip here again as sanitize_message in parse_options already does it.
|
532
|
+
user_instructions_section = "User's Custom Instructions:\n#{user_prompt_addition}\n\n---\n\n"
|
533
|
+
end
|
534
|
+
|
535
|
+
requirements_section = ""
|
536
|
+
if requirements_content && !requirements_content.to_s.strip.empty?
|
537
|
+
requirements_section = <<-REQUIREMENTS_BLOCK
|
538
|
+
CRITICAL REQUIREMENTS EVALUATION:
|
539
|
+
Based on the provided context, evaluate if the code changes meet these requirements.
|
540
|
+
For each requirement, explicitly state whether it is:
|
541
|
+
- ✅ IMPLEMENTED
|
542
|
+
- ⚠️ PARTIALLY IMPLEMENTED
|
543
|
+
- ❌ NOT IMPLEMENTED
|
544
|
+
- 🔍 UNCLEAR
|
545
|
+
|
546
|
+
--- BEGIN REQUIREMENTS ---
|
547
|
+
#{requirements_content.strip}
|
548
|
+
--- END REQUIREMENTS ---
|
549
|
+
|
550
|
+
REQUIREMENTS_BLOCK
|
551
|
+
end
|
552
|
+
|
553
|
+
analysis_intro = "Analyze the following code diff. Focus on identifying potential bugs, suggesting improvements, and assessing test coverage. If requirements are provided, evaluate against them."
|
554
|
+
|
555
|
+
# Context extraction (can be intensive, consider making optional or smarter)
|
556
|
+
# context_sections = extract_code_context_from_diff(diff_output) # This can be slow
|
557
|
+
# context_info = context_sections.empty? ? "" : "\n\nRelevant Code Context:\n#{format_context_for_prompt(context_sections)}"
|
558
|
+
# For now, let's keep context extraction simpler or rely on LLM's ability with just the diff.
|
559
|
+
# If extract_code_context_from_diff is too complex or slow, it might be omitted or simplified.
|
560
|
+
# For this integration, assuming extract_code_context_from_diff is efficient enough.
|
561
|
+
context_sections = extract_code_context_from_diff(diff_output)
|
562
|
+
context_info = ""
|
563
|
+
unless context_sections.empty?
|
564
|
+
context_info = "\n\nCurrent Code Context (for better analysis):\n"
|
565
|
+
context_sections.each do |file_path, sections|
|
566
|
+
context_info += "\n--- Context from: #{file_path} ---\n"
|
567
|
+
sections.each do |section|
|
568
|
+
context_info += "Lines approx #{section[:start_line]}-#{section[:end_line]}:\n" # Approximate lines
|
569
|
+
context_info += "```\n#{section[:content]}\n```\n\n"
|
570
|
+
end
|
571
|
+
end
|
572
|
+
end
|
573
|
+
|
574
|
+
|
575
|
+
json_instruction_path = resolve_template_path('diff_json_instruction', config) # Ensure this template exists
|
576
|
+
json_instruction = File.read(json_instruction_path).strip rescue 'Respond in JSON format with keys: "summary", "errors", "improvements", "test_coverage", "requirements_evaluation", "ticket_implementation_summary".' # Fallback
|
577
|
+
|
578
|
+
full_prompt = [
|
579
|
+
user_instructions_section, # Custom instructions first
|
580
|
+
default_system_prompt,
|
581
|
+
requirements_section,
|
582
|
+
analysis_intro,
|
583
|
+
"Diff:\n```diff\n#{diff_output}\n```", # Ensure diff is marked as diff language
|
584
|
+
context_info,
|
585
|
+
json_instruction
|
586
|
+
].select { |s| s && !s.strip.empty? }.join("\n\n")
|
587
|
+
|
588
|
+
full_prompt
|
589
|
+
end
|
590
|
+
|
591
|
+
def analyze_diff(diff_output, config, user_prompt_addition = "", requirements_content = nil)
|
592
|
+
# user_prompt_addition here IS the custom_message from options
|
593
|
+
prompt = build_diff_analysis_prompt(diff_output, user_prompt_addition, requirements_content, config)
|
594
|
+
|
595
|
+
# Use analyze_diff_with_spinner for this call
|
596
|
+
analysis_json_str = analyze_diff_with_spinner(config) do |llm| # Pass config to spinner wrapper
|
597
|
+
llm.analyze_code_diff(prompt) # This is the actual call the spinner will make
|
598
|
+
end
|
599
|
+
# Fallback if spinner itself has an issue or if LLM call within spinner fails before LlmApiError
|
600
|
+
analysis_json_str ||= '{"summary": "Error: Failed to get analysis from LLM.", "errors": [], "improvements": []}'
|
601
|
+
|
602
|
+
|
603
|
+
begin
|
604
|
+
json_content = extract_json_from_response(analysis_json_str)
|
605
|
+
analysis_result = JSON.parse(json_content)
|
606
|
+
|
607
|
+
# Output formatting (consider making this a separate method or class)
|
608
|
+
puts "\n#{COLOR_BLUE}📊 Code Diff Analysis:#{COLOR_RESET}"
|
609
|
+
puts "-------------------"
|
610
|
+
puts "#{COLOR_GREEN}Summary:#{COLOR_RESET}"
|
611
|
+
puts analysis_result['summary'] || "No summary provided."
|
612
|
+
|
613
|
+
puts "\n#{COLOR_RED}Potential Issues/Errors:#{COLOR_RESET}"
|
614
|
+
errors_list = [analysis_result['errors']].flatten.compact.reject(&:empty?)
|
615
|
+
puts errors_list.any? ? errors_list.map { |err| "- #{err}" }.join("\n") : " No specific errors identified."
|
616
|
+
|
617
|
+
puts "\n#{COLOR_YELLOW}Suggested Improvements:#{COLOR_RESET}"
|
618
|
+
improvements_list = [analysis_result['improvements']].flatten.compact.reject(&:empty?)
|
619
|
+
puts improvements_list.any? ? improvements_list.map { |imp| "- #{imp}" }.join("\n") : " No specific improvements suggested."
|
620
|
+
|
621
|
+
puts "\n#{COLOR_BLUE}Test Coverage Assessment:#{COLOR_RESET}"
|
622
|
+
test_coverage = analysis_result['test_coverage']
|
623
|
+
puts test_coverage && !test_coverage.to_s.strip.empty? ? test_coverage : " No test coverage assessment provided."
|
624
|
+
|
625
|
+
if requirements_content && !requirements_content.to_s.strip.empty?
|
626
|
+
puts "\n#{COLOR_GREEN}Requirements Evaluation:#{COLOR_RESET}"
|
627
|
+
eval_text = analysis_result['requirements_evaluation']
|
628
|
+
puts eval_text && !eval_text.to_s.strip.empty? ? eval_text : " No requirements evaluation provided."
|
629
|
+
end
|
630
|
+
|
631
|
+
# This field is often more specific for tickets
|
632
|
+
ticket_summary = analysis_result['ticket_implementation_summary']
|
633
|
+
if ticket_summary && !ticket_summary.to_s.strip.empty?
|
634
|
+
puts "\n#{COLOR_GREEN}Ticket Implementation Summary:#{COLOR_RESET}"
|
635
|
+
puts ticket_summary
|
636
|
+
end
|
637
|
+
|
638
|
+
puts "-------------------"
|
639
|
+
return analysis_result
|
640
|
+
rescue JSON::ParserError => e
|
641
|
+
puts "#{COLOR_RED}Critical Error: Failed to parse JSON response for diff analysis: #{e.message}#{COLOR_RESET}"
|
642
|
+
puts "Raw response was: #{analysis_json_str}"
|
643
|
+
# Fallback if parsing fails
|
644
|
+
return {"summary" => "Failed to parse analysis.", "errors" => ["JSON parsing error."], "improvements" => [], "test_coverage" => "Unknown", "requirements_evaluation" => "Unknown", "ticket_implementation_summary" => "Unknown"}
|
645
|
+
end
|
646
|
+
end
|
647
|
+
|
648
|
+
# Extracted from original N2B::CLI, might need slight path adjustments if templates are structured differently
|
649
|
+
# def resolve_template_path(template_key, config)
|
650
|
+
# user_path = config.dig('templates', template_key) if config.is_a?(Hash)
|
651
|
+
# return user_path if user_path && File.exist?(user_path)
|
652
|
+
# # Assuming templates are in a subdir relative to THIS file (merge_cli.rb)
|
653
|
+
# File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
|
654
|
+
# end
|
655
|
+
|
656
|
+
|
657
|
+
def format_analysis_for_jira(analysis_result, custom_message = nil)
|
658
|
+
# Basic check, ensure analysis_result is a hash
|
659
|
+
return { body: "Error: Analysis result is not in the expected format." } unless analysis_result.is_a?(Hash)
|
660
|
+
|
661
|
+
summary = analysis_result['ticket_implementation_summary']&.strip || analysis_result['summary']&.strip || "No summary."
|
662
|
+
|
663
|
+
details_parts = []
|
664
|
+
details_parts << "*Custom Analysis Focus:*\n#{custom_message}\n" if custom_message && !custom_message.empty?
|
665
|
+
details_parts << "*Technical Summary:*\n#{analysis_result['summary']&.strip}\n" if analysis_result['summary']
|
666
|
+
|
667
|
+
issues = format_issues_for_adf(analysis_result['errors'])
|
668
|
+
improvements = format_improvements_for_adf(analysis_result['improvements'])
|
669
|
+
|
670
|
+
details_parts << "*Potential Issues:*\n" + (issues.empty? ? "None identified.\n" : issues.map { |i| "- #{i}\n" }.join)
|
671
|
+
details_parts << "*Suggested Improvements:*\n" + (improvements.empty? ? "None identified.\n" : improvements.map { |i| "- #{i}\n" }.join)
|
672
|
+
details_parts << "*Test Coverage Assessment:*\n#{analysis_result['test_coverage']&.strip || "Not assessed."}\n"
|
673
|
+
|
674
|
+
req_eval = analysis_result['requirements_evaluation']&.strip
|
675
|
+
details_parts << "*Requirements Evaluation:*\n#{req_eval}\n" if req_eval && !req_eval.empty?
|
676
|
+
|
677
|
+
# ADF structure for Jira
|
678
|
+
# This is a simplified version. Real ADF is more complex.
|
679
|
+
# N2B::JiraClient would need to construct the actual ADF document.
|
680
|
+
# Here, we are returning a hash that the client can use.
|
681
|
+
{
|
682
|
+
implementation_summary: summary, # Often used as a primary comment or field
|
683
|
+
technical_summary: analysis_result['summary']&.strip,
|
684
|
+
issues: issues, # Array of strings
|
685
|
+
improvements: improvements, # Array of strings
|
686
|
+
test_coverage: analysis_result['test_coverage']&.strip,
|
687
|
+
requirements_evaluation: req_eval,
|
688
|
+
custom_analysis_focus: custom_message # Added new key
|
689
|
+
}
|
690
|
+
end
|
691
|
+
|
692
|
+
def format_issues_for_adf(errors) # Helper for Jira/GitHub formatting
|
693
|
+
return [] unless errors.is_a?(Array) && errors.any?
|
694
|
+
errors.map(&:strip).reject(&:empty?)
|
695
|
+
end
|
696
|
+
|
697
|
+
def format_improvements_for_adf(improvements) # Helper for Jira/GitHub formatting
|
698
|
+
return [] unless improvements.is_a?(Array) && improvements.any?
|
699
|
+
improvements.map(&:strip).reject(&:empty?)
|
700
|
+
end
|
701
|
+
|
702
|
+
def format_analysis_for_github(analysis_result, custom_message = nil)
|
703
|
+
# Basic check
|
704
|
+
return "Error: Analysis result is not in the expected format." unless analysis_result.is_a?(Hash)
|
705
|
+
|
706
|
+
title = "### Code Diff Analysis Summary 🤖\n"
|
707
|
+
summary_section = "**Implementation/Overall Summary:**\n#{analysis_result['ticket_implementation_summary']&.strip || analysis_result['summary']&.strip || "No summary provided."}\n\n"
|
708
|
+
|
709
|
+
details_parts = []
|
710
|
+
details_parts << "**Custom Analysis Focus:**\n#{custom_message}\n" if custom_message && !custom_message.empty?
|
711
|
+
details_parts << "**Technical Detail Summary:**\n#{analysis_result['summary']&.strip}\n" if analysis_result['summary'] && analysis_result['summary'] != analysis_result['ticket_implementation_summary']
|
712
|
+
|
713
|
+
|
714
|
+
errors_list = [analysis_result['errors']].flatten.compact.reject(&:empty?)
|
715
|
+
details_parts << "**Potential Issues/Errors:**\n" + (errors_list.empty? ? "_None identified._\n" : errors_list.map { |err| "- [ ] #{err}\n" }.join) # Added checkbox
|
716
|
+
|
717
|
+
improvements_list = [analysis_result['improvements']].flatten.compact.reject(&:empty?)
|
718
|
+
details_parts << "**Suggested Improvements:**\n" + (improvements_list.empty? ? "_None identified._\n" : improvements_list.map { |imp| "- [ ] #{imp}\n" }.join) # Added checkbox
|
719
|
+
|
720
|
+
details_parts << "**Test Coverage Assessment:**\n#{analysis_result['test_coverage']&.strip || "_Not assessed._"}\n"
|
721
|
+
|
722
|
+
req_eval = analysis_result['requirements_evaluation']&.strip
|
723
|
+
details_parts << "**Requirements Evaluation:**\n#{req_eval.empty? ? "_Not applicable or not assessed._" : req_eval}\n" if req_eval
|
724
|
+
|
725
|
+
# For GitHub, typically a Markdown string is returned for the comment body.
|
726
|
+
# The N2B::GitHubClient would take this string.
|
727
|
+
# Returning a hash that the client can use to build the comment.
|
728
|
+
{
|
729
|
+
title: "Code Diff Analysis Summary 🤖",
|
730
|
+
implementation_summary: analysis_result['ticket_implementation_summary']&.strip || analysis_result['summary']&.strip,
|
731
|
+
technical_summary: analysis_result['summary']&.strip,
|
732
|
+
issues: errors_list, # Array of strings
|
733
|
+
improvements: improvements_list, # Array of strings
|
734
|
+
test_coverage: analysis_result['test_coverage']&.strip,
|
735
|
+
requirements_evaluation: req_eval,
|
736
|
+
custom_analysis_focus: custom_message, # Added new key
|
737
|
+
# Construct a body for convenience, client can override
|
738
|
+
body: title + summary_section + details_parts.join("\n")
|
739
|
+
}
|
740
|
+
end
|
741
|
+
|
742
|
+
def extract_json_from_response(response)
|
743
|
+
# This is a robust way to extract JSON that might be embedded in other text.
|
744
|
+
# First, try to parse as-is, in case the LLM behaves perfectly.
|
745
|
+
begin
|
746
|
+
JSON.parse(response)
|
747
|
+
return response # It's already valid JSON
|
748
|
+
rescue JSON::ParserError
|
749
|
+
# If not, search for the first '{' and last '}'
|
750
|
+
end
|
751
|
+
|
752
|
+
json_start = response.index('{')
|
753
|
+
json_end = response.rindex('}') # Use rindex for the last occurrence
|
754
|
+
|
755
|
+
if json_start && json_end && json_end > json_start
|
756
|
+
potential_json = response[json_start..json_end]
|
757
|
+
begin
|
758
|
+
JSON.parse(potential_json) # Check if this substring is valid JSON
|
759
|
+
return potential_json
|
760
|
+
rescue JSON::ParserError
|
761
|
+
# Fallback: if the strict extraction fails, return the original response
|
762
|
+
# and let the caller deal with a more comprehensive repair or error.
|
763
|
+
# This can happen if there are '{' or '}' in string literals within the JSON.
|
764
|
+
# A more sophisticated parser would be needed for those cases.
|
765
|
+
# For now, this is a common heuristic.
|
766
|
+
return response # Or perhaps an error string/nil
|
767
|
+
end
|
768
|
+
else
|
769
|
+
# No clear JSON structure found, return original response for error handling
|
770
|
+
return response
|
771
|
+
end
|
772
|
+
end
|
773
|
+
|
774
|
+
def extract_code_context_from_diff(diff_output, lines_of_context: 5)
|
775
|
+
# Simplified context extraction focusing on lines around changes.
|
776
|
+
# This is a placeholder for potentially more sophisticated context extraction.
|
777
|
+
# A full AST parse or more complex diff parsing could be used for richer context.
|
778
|
+
context_sections = {}
|
779
|
+
current_file = nil
|
780
|
+
file_lines_buffer = {} # Cache for file lines
|
781
|
+
|
782
|
+
# First pass: identify files and changed line numbers from hunk headers
|
783
|
+
# @@ -old_start,old_lines +new_start,new_lines
|
784
|
+
hunks_by_file = {}
|
785
|
+
diff_output.each_line do |line|
|
786
|
+
line.chomp!
|
787
|
+
if line.start_with?('diff --git')
|
788
|
+
# Extracts 'b_file_path' from "diff --git a/a_file_path b/b_file_path"
|
789
|
+
match = line.match(/diff --git a\/(.+?) b\/(.+)/)
|
790
|
+
current_file = match[2] if match && match[2] != '/dev/null'
|
791
|
+
elsif line.start_with?('+++ b/')
|
792
|
+
# Extracts file path from "+++ b/file_path"
|
793
|
+
current_file = line[6..].strip unless line.include?('/dev/null')
|
794
|
+
elsif line.start_with?('@@') && current_file
|
795
|
+
match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
|
796
|
+
next unless match
|
797
|
+
start_line = match[1].to_i
|
798
|
+
# hunk_lines = match[2]&.to_i || 1 # Number of lines in the hunk in the new file
|
799
|
+
# For context, we care more about the range of lines around the start_line of the change.
|
800
|
+
hunks_by_file[current_file] ||= []
|
801
|
+
hunks_by_file[current_file] << { new_start: start_line }
|
802
|
+
end
|
803
|
+
end
|
804
|
+
|
805
|
+
# Second pass: extract context for each hunk
|
806
|
+
hunks_by_file.each do |file_path, hunks|
|
807
|
+
next unless File.exist?(file_path) # Ensure file exists to read context from
|
808
|
+
|
809
|
+
file_lines_buffer[file_path] ||= File.readlines(file_path, chomp: true)
|
810
|
+
all_lines = file_lines_buffer[file_path]
|
811
|
+
|
812
|
+
hunks.each do |hunk|
|
813
|
+
context_start_line = [1, hunk[:new_start] - lines_of_context].max
|
814
|
+
# Estimate end of change based on typical hunk sizes or simplify
|
815
|
+
# For simplicity, take fixed lines after start, or up to next hunk.
|
816
|
+
# This is a very rough heuristic.
|
817
|
+
context_end_line = [all_lines.length, hunk[:new_start] + lines_of_context + 5].min # +5 for some change content
|
818
|
+
|
819
|
+
actual_start_index = context_start_line - 1
|
820
|
+
actual_end_index = context_end_line - 1
|
821
|
+
|
822
|
+
if actual_start_index < all_lines.length && actual_start_index <= actual_end_index
|
823
|
+
section_content = all_lines[actual_start_index..actual_end_index].join("\n")
|
824
|
+
context_sections[file_path] ||= []
|
825
|
+
# Avoid duplicate sections if hunks are very close
|
826
|
+
unless context_sections[file_path].any? { |s| s[:content] == section_content }
|
827
|
+
context_sections[file_path] << {
|
828
|
+
# Store actual line numbers for reference, not just indices
|
829
|
+
start_line: context_start_line,
|
830
|
+
end_line: context_end_line,
|
831
|
+
content: section_content
|
832
|
+
}
|
833
|
+
end
|
834
|
+
end
|
835
|
+
end
|
836
|
+
end
|
837
|
+
context_sections
|
838
|
+
end
|
839
|
+
|
840
|
+
# Spinner method specifically for diff analysis
|
841
|
+
def analyze_diff_with_spinner(config) # Takes config to initialize LLM
|
842
|
+
llm_service_name = config['llm']
|
843
|
+
llm = case llm_service_name # Initialize LLM based on config
|
844
|
+
when 'openai' then N2B::Llm::OpenAi.new(config)
|
845
|
+
when 'claude' then N2B::Llm::Claude.new(config)
|
846
|
+
when 'gemini' then N2B::Llm::Gemini.new(config)
|
847
|
+
when 'openrouter' then N2B::Llm::OpenRouter.new(config)
|
848
|
+
when 'ollama' then N2B::Llm::Ollama.new(config)
|
849
|
+
else raise N2B::Error, "Unsupported LLM service for analysis: #{llm_service_name}"
|
850
|
+
end
|
851
|
+
|
852
|
+
spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
853
|
+
spinner_thread = Thread.new do
|
854
|
+
i = 0
|
855
|
+
loop do
|
856
|
+
print "\r#{COLOR_BLUE}🔍 #{spinner_chars[i % spinner_chars.length]} Analyzing diff...#{COLOR_RESET}"
|
857
|
+
$stdout.flush
|
858
|
+
sleep(0.1)
|
859
|
+
i += 1
|
860
|
+
end
|
861
|
+
end
|
862
|
+
|
863
|
+
begin
|
864
|
+
# The block passed to this method contains the actual LLM call
|
865
|
+
result = yield(llm) if block_given?
|
866
|
+
spinner_thread.kill
|
867
|
+
spinner_thread.join # Ensure thread is fully cleaned up
|
868
|
+
print "\r#{' ' * 35}\r" # Clear spinner line
|
869
|
+
puts "#{COLOR_GREEN}✅ Diff analysis complete!#{COLOR_RESET}"
|
870
|
+
result
|
871
|
+
rescue N2B::LlmApiError => e
|
872
|
+
spinner_thread.kill
|
873
|
+
spinner_thread.join
|
874
|
+
print "\r#{' ' * 35}\r"
|
875
|
+
puts "#{COLOR_RED}LLM API Error during diff analysis: #{e.message}#{COLOR_RESET}"
|
876
|
+
# Provide specific model error guidance
|
877
|
+
if e.message.match?(/model|invalid|not found/i)
|
878
|
+
puts "#{COLOR_YELLOW}This might be due to an invalid or unsupported model in your config. Run 'n2b -c' to reconfigure.#{COLOR_RESET}"
|
879
|
+
end
|
880
|
+
# Return a structured error JSON
|
881
|
+
'{"summary": "Error: Could not analyze diff due to LLM API error.", "errors": ["#{e.message}"], "improvements": []}'
|
882
|
+
rescue StandardError => e # Catch other unexpected errors during the yield or LLM call
|
883
|
+
spinner_thread.kill
|
884
|
+
spinner_thread.join
|
885
|
+
print "\r#{' ' * 35}\r"
|
886
|
+
puts "#{COLOR_RED}Unexpected error during diff analysis: #{e.message}#{COLOR_RESET}"
|
887
|
+
'{"summary": "Error: Unexpected failure during diff analysis.", "errors": ["#{e.message}"], "improvements": []}'
|
888
|
+
end
|
889
|
+
end
|
890
|
+
# --- End of moved methods ---
|
891
|
+
|
892
|
+
|
112
893
|
def resolve_block(block, config, full_file_content)
|
113
894
|
comment = nil
|
114
895
|
|
@@ -121,44 +902,107 @@ module N2B
|
|
121
902
|
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
122
903
|
puts "#{COLOR_GREEN}✅ Initial suggestion ready!#{COLOR_RESET}\n"
|
123
904
|
|
124
|
-
|
125
|
-
print_conflict(block)
|
126
|
-
print_suggestion(suggestion)
|
127
|
-
print "#{COLOR_YELLOW}Accept [y], Skip [n], Comment [c], Edit [e], Abort [a] (explicit choice required): #{COLOR_RESET}"
|
128
|
-
choice = $stdin.gets&.strip&.downcase
|
905
|
+
vcs_type = get_vcs_type_for_file_operations
|
129
906
|
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
907
|
+
# Convert conflict labels to actual VCS revision identifiers
|
908
|
+
base_revision = convert_label_to_revision(block.base_label, vcs_type, :base)
|
909
|
+
incoming_revision = convert_label_to_revision(block.incoming_label, vcs_type, :incoming)
|
910
|
+
|
911
|
+
base_content_full = get_file_content_from_vcs(base_revision, @file_path, vcs_type) || difficulté_to_load_content_placeholder("base content from #{block.base_label}")
|
912
|
+
incoming_content_full = get_file_content_from_vcs(incoming_revision, @file_path, vcs_type) || difficulté_to_load_content_placeholder("incoming content from #{block.incoming_label}")
|
913
|
+
|
914
|
+
generated_html_path = nil
|
915
|
+
|
916
|
+
begin
|
917
|
+
loop do
|
918
|
+
current_resolution_content_full = apply_hunk_to_full_content(full_file_content, block, suggestion['merged_code'])
|
919
|
+
|
920
|
+
# Don't delete the HTML file immediately - keep it available for user preview
|
921
|
+
|
922
|
+
generated_html_path = generate_conflict_preview_html(
|
923
|
+
block,
|
924
|
+
base_content_full,
|
925
|
+
incoming_content_full,
|
926
|
+
current_resolution_content_full,
|
927
|
+
block.base_label,
|
928
|
+
block.incoming_label,
|
929
|
+
@file_path,
|
930
|
+
suggestion
|
931
|
+
)
|
932
|
+
|
933
|
+
preview_link_message = ""
|
934
|
+
if generated_html_path && File.exist?(generated_html_path)
|
935
|
+
preview_link_message = "🌐 #{COLOR_BLUE}Preview: file://#{generated_html_path}#{COLOR_RESET}"
|
936
|
+
else
|
937
|
+
preview_link_message = "#{COLOR_YELLOW}⚠️ Could not generate HTML preview.#{COLOR_RESET}"
|
938
|
+
end
|
939
|
+
puts preview_link_message
|
940
|
+
|
941
|
+
print_conflict(block)
|
942
|
+
print_suggestion(suggestion)
|
943
|
+
|
944
|
+
prompt_message = <<~PROMPT
|
945
|
+
#{COLOR_YELLOW}Actions: [y] Accept, [n] Skip, [c] Comment, [e] Edit, [p] Preview, [s] Refresh, [a] Abort#{COLOR_RESET}
|
946
|
+
#{COLOR_GRAY}(Preview link above can be cmd/ctrl+clicked if your terminal supports it){COLOR_RESET}
|
947
|
+
#{COLOR_YELLOW}Your choice: #{COLOR_RESET}
|
948
|
+
PROMPT
|
949
|
+
print prompt_message
|
950
|
+
choice = $stdin.gets&.strip&.downcase
|
951
|
+
|
952
|
+
case choice
|
953
|
+
when 'y'
|
954
|
+
return {accepted: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
|
955
|
+
when 'n'
|
956
|
+
return {accepted: false, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
|
957
|
+
when 'c'
|
958
|
+
puts 'Enter comment (end with blank line):'
|
959
|
+
comment = read_multiline_input
|
960
|
+
puts "#{COLOR_YELLOW}🤖 AI is analyzing your comment and generating new suggestion...#{COLOR_RESET}"
|
961
|
+
current_file_on_disk = File.exist?(@file_path) ? File.read(@file_path) : full_file_content
|
962
|
+
full_file_content = current_file_on_disk
|
963
|
+
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
964
|
+
puts "#{COLOR_GREEN}✅ New suggestion ready!#{COLOR_RESET}\n"
|
965
|
+
# Loop continues, will regenerate preview
|
966
|
+
when 'e'
|
967
|
+
edit_result = handle_editor_workflow(block, config, full_file_content)
|
968
|
+
if edit_result[:resolved]
|
969
|
+
return {accepted: true, merged_code: edit_result[:merged_code], reason: edit_result[:reason], comment: comment}
|
970
|
+
elsif edit_result[:updated_content]
|
971
|
+
full_file_content = edit_result[:updated_content]
|
972
|
+
puts "#{COLOR_YELLOW}🤖 Content changed by editor. Re-analyzing for new suggestion...#{COLOR_RESET}"
|
973
|
+
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
974
|
+
end
|
975
|
+
# Loop continues, will regenerate preview
|
976
|
+
when 'p' # Open Preview in Browser
|
977
|
+
if generated_html_path && File.exist?(generated_html_path)
|
978
|
+
puts "#{COLOR_BLUE}🌐 Opening preview in browser...#{COLOR_RESET}"
|
979
|
+
open_html_in_browser(generated_html_path)
|
980
|
+
else
|
981
|
+
puts "#{COLOR_YELLOW}⚠️ No preview available to open.#{COLOR_RESET}"
|
982
|
+
end
|
983
|
+
# Loop continues, no changes to suggestion
|
984
|
+
when 's' # Refresh Preview
|
985
|
+
puts "#{COLOR_BLUE}🔄 Refreshing suggestion and preview...#{COLOR_RESET}"
|
986
|
+
suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
|
987
|
+
# Loop continues, preview will be regenerated
|
988
|
+
when 'a'
|
989
|
+
return {abort: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
|
990
|
+
when '', nil
|
991
|
+
puts "#{COLOR_RED}Please enter a valid choice.#{COLOR_RESET}"
|
992
|
+
else
|
993
|
+
puts "#{COLOR_RED}Invalid option. Please choose from the available actions.#{COLOR_RESET}"
|
150
994
|
end
|
151
|
-
# Continue the loop with potentially updated content
|
152
|
-
when 'a'
|
153
|
-
return {abort: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
|
154
|
-
when '', nil
|
155
|
-
puts "#{COLOR_RED}Please enter a valid choice: y/n/c/e/a#{COLOR_RESET}"
|
156
|
-
else
|
157
|
-
puts "#{COLOR_RED}Invalid option. Please enter: y (accept), n (skip), c (comment), e (edit), or a (abort)#{COLOR_RESET}"
|
158
995
|
end
|
996
|
+
ensure
|
997
|
+
FileUtils.rm_f(generated_html_path) if generated_html_path && File.exist?(generated_html_path)
|
159
998
|
end
|
160
999
|
end
|
161
1000
|
|
1001
|
+
def difficulté_to_load_content_placeholder(description)
|
1002
|
+
# Helper to return a placeholder if VCS content fails, aiding debug in preview
|
1003
|
+
"N2B: Could not load #{description}. Displaying this placeholder."
|
1004
|
+
end
|
1005
|
+
|
162
1006
|
def request_merge(block, config, comment, full_file_content)
|
163
1007
|
prompt = build_merge_prompt(block, comment, full_file_content)
|
164
1008
|
json_str = call_llm_for_merge(prompt, config)
|
@@ -219,6 +1063,8 @@ module N2B
|
|
219
1063
|
user_comment_text = comment && !comment.empty? ? "User comment: #{comment}" : ""
|
220
1064
|
|
221
1065
|
template.gsub('{full_file_content}', full_file_content.to_s)
|
1066
|
+
.gsub('{start_line}', block.start_line.to_s)
|
1067
|
+
.gsub('{end_line}', block.end_line.to_s)
|
222
1068
|
.gsub('{context_before}', block.context_before.to_s)
|
223
1069
|
.gsub('{base_label}', block.base_label.to_s)
|
224
1070
|
.gsub('{base_content}', block.base_content.to_s)
|
@@ -232,15 +1078,15 @@ module N2B
|
|
232
1078
|
llm_service_name = config['llm']
|
233
1079
|
llm = case llm_service_name
|
234
1080
|
when 'openai'
|
235
|
-
|
1081
|
+
N2B::Llm::OpenAi.new(config)
|
236
1082
|
when 'claude'
|
237
|
-
|
1083
|
+
N2B::Llm::Claude.new(config)
|
238
1084
|
when 'gemini'
|
239
|
-
|
1085
|
+
N2B::Llm::Gemini.new(config)
|
240
1086
|
when 'openrouter'
|
241
|
-
|
1087
|
+
N2B::Llm::OpenRouter.new(config)
|
242
1088
|
when 'ollama'
|
243
|
-
|
1089
|
+
N2B::Llm::Ollama.new(config)
|
244
1090
|
else
|
245
1091
|
raise N2B::Error, "Unsupported LLM service: #{llm_service_name}"
|
246
1092
|
end
|
@@ -408,11 +1254,25 @@ module N2B
|
|
408
1254
|
end
|
409
1255
|
|
410
1256
|
def print_conflict(block)
|
1257
|
+
# Show context before conflict for better understanding
|
1258
|
+
if block.context_before && !block.context_before.empty?
|
1259
|
+
puts "#{COLOR_GRAY}... context before ...#{COLOR_RESET}"
|
1260
|
+
context_lines = block.context_before.split("\n").last(3) # Show last 3 lines of context
|
1261
|
+
context_lines.each { |line| puts "#{COLOR_GRAY}#{line}#{COLOR_RESET}" }
|
1262
|
+
end
|
1263
|
+
|
411
1264
|
puts "#{COLOR_RED}<<<<<<< #{block.base_label} (lines #{block.start_line}-#{block.end_line})#{COLOR_RESET}"
|
412
1265
|
puts "#{COLOR_RED}#{block.base_content}#{COLOR_RESET}"
|
413
1266
|
puts "#{COLOR_YELLOW}=======#{COLOR_RESET}"
|
414
1267
|
puts "#{COLOR_GREEN}#{block.incoming_content}#{COLOR_RESET}"
|
415
1268
|
puts "#{COLOR_YELLOW}>>>>>>> #{block.incoming_label}#{COLOR_RESET}"
|
1269
|
+
|
1270
|
+
# Show context after conflict for better understanding
|
1271
|
+
if block.context_after && !block.context_after.empty?
|
1272
|
+
context_lines = block.context_after.split("\n").first(3) # Show first 3 lines of context
|
1273
|
+
context_lines.each { |line| puts "#{COLOR_GRAY}#{line}#{COLOR_RESET}" }
|
1274
|
+
puts "#{COLOR_GRAY}... context after ...#{COLOR_RESET}"
|
1275
|
+
end
|
416
1276
|
end
|
417
1277
|
|
418
1278
|
def print_suggestion(sug)
|
@@ -493,50 +1353,68 @@ module N2B
|
|
493
1353
|
end
|
494
1354
|
end
|
495
1355
|
|
496
|
-
def
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
#
|
501
|
-
|
502
|
-
|
503
|
-
|
1356
|
+
def show_help_and_status
|
1357
|
+
# This method will be invoked by OptionParser for -h/--help,
|
1358
|
+
# or when n2b-diff is run without arguments in non-analyze mode.
|
1359
|
+
# The OptionParser instance itself will print most of the help text.
|
1360
|
+
# We just add any extra status info here.
|
1361
|
+
|
1362
|
+
puts "" # Extra newline for spacing after OptionParser's output if it called this.
|
1363
|
+
# If running not due to -h, but due to missing file_path in merge mode:
|
1364
|
+
if !@options[:analyze] && @file_path.nil? && !@args.include?('-h') && !@args.include?('--help')
|
1365
|
+
puts "#{COLOR_RED}Error: No file path provided for merge conflict resolution."
|
1366
|
+
puts "Run with -h or --help for detailed usage."
|
1367
|
+
puts ""
|
1368
|
+
end
|
504
1369
|
|
505
|
-
if result[:success]
|
506
|
-
unresolved_files = result[:stdout].lines.select { |line| line.start_with?('U ') }
|
507
1370
|
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
1371
|
+
# Show unresolved conflicts if in a VCS repository and not in analyze mode
|
1372
|
+
# (or if specifically requested, but for now, tied to non-analyze mode)
|
1373
|
+
if !@options[:analyze] && (File.exist?('.hg') || File.exist?('.git'))
|
1374
|
+
puts "#{COLOR_BLUE}📋 Unresolved Conflicts Status:#{COLOR_RESET}"
|
1375
|
+
if File.exist?('.hg')
|
1376
|
+
puts "#{COLOR_GRAY}Checking Mercurial...#{COLOR_RESET}"
|
1377
|
+
result = execute_vcs_command_with_timeout("hg resolve --list", 5)
|
1378
|
+
if result[:success]
|
1379
|
+
unresolved_files = result[:stdout].lines.select { |line| line.start_with?('U ') }
|
1380
|
+
if unresolved_files.any?
|
1381
|
+
unresolved_files.each { |line| puts " #{COLOR_RED}❌ #{line.strip.sub(/^U /, '')} (Mercurial)#{COLOR_RESET}" }
|
1382
|
+
puts "\n#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve listed conflicts.#{COLOR_RESET}"
|
1383
|
+
else
|
1384
|
+
puts " #{COLOR_GREEN}✅ No unresolved Mercurial conflicts.#{COLOR_RESET}"
|
512
1385
|
end
|
513
|
-
puts ""
|
514
|
-
puts "#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve conflicts#{COLOR_RESET}"
|
515
1386
|
else
|
516
|
-
puts " #{
|
1387
|
+
puts " #{COLOR_YELLOW}⚠️ Could not check Mercurial status: #{result[:error]}#{COLOR_RESET}"
|
517
1388
|
end
|
518
|
-
else
|
519
|
-
puts " #{COLOR_YELLOW}⚠️ Could not check Mercurial status: #{result[:error]}#{COLOR_RESET}"
|
520
1389
|
end
|
521
|
-
elsif File.exist?('.git')
|
522
|
-
puts "#{COLOR_BLUE}📋 Unresolved conflicts in Git:#{COLOR_RESET}"
|
523
|
-
result = execute_vcs_command_with_timeout("git diff --name-only --diff-filter=U", 5)
|
524
|
-
|
525
|
-
if result[:success]
|
526
|
-
unresolved_files = result[:stdout].lines
|
527
1390
|
|
528
|
-
|
529
|
-
|
530
|
-
|
1391
|
+
if File.exist?('.git')
|
1392
|
+
puts "#{COLOR_GRAY}Checking Git...#{COLOR_RESET}"
|
1393
|
+
# For Git, `git status --porcelain` is better as `git diff --name-only --diff-filter=U` only shows unmerged paths.
|
1394
|
+
# We want to show files with conflict markers.
|
1395
|
+
# `git status --porcelain=v1` shows "UU" for unmerged files.
|
1396
|
+
result = execute_vcs_command_with_timeout("git status --porcelain=v1", 5)
|
1397
|
+
if result[:success]
|
1398
|
+
unresolved_files = result[:stdout].lines.select{|line| line.start_with?('UU ')}.map{|line| line.sub(/^UU /, '').strip}
|
1399
|
+
if unresolved_files.any?
|
1400
|
+
unresolved_files.each { |file| puts " #{COLOR_RED}❌ #{file} (Git)#{COLOR_RESET}" }
|
1401
|
+
puts "\n#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve listed conflicts.#{COLOR_RESET}"
|
1402
|
+
else
|
1403
|
+
puts " #{COLOR_GREEN}✅ No unresolved Git conflicts.#{COLOR_RESET}"
|
531
1404
|
end
|
532
|
-
puts ""
|
533
|
-
puts "#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve conflicts#{COLOR_RESET}"
|
534
1405
|
else
|
535
|
-
puts " #{
|
1406
|
+
puts " #{COLOR_YELLOW}⚠️ Could not check Git status: #{result[:error]}#{COLOR_RESET}"
|
536
1407
|
end
|
537
|
-
else
|
538
|
-
puts " #{COLOR_YELLOW}⚠️ Could not check Git status: #{result[:error]}#{COLOR_RESET}"
|
539
1408
|
end
|
1409
|
+
elsif @options[:analyze]
|
1410
|
+
# This part might not be reached if -h is used, as OptionParser exits.
|
1411
|
+
# But if called for other reasons in analyze mode:
|
1412
|
+
puts "#{COLOR_BLUE}ℹ️ Running in analysis mode. VCS conflict status check is skipped.#{COLOR_RESET}"
|
1413
|
+
end
|
1414
|
+
# Ensure exit if we got here due to an operational error (like no file in merge mode)
|
1415
|
+
# but not if it was just -h (OptionParser handles exit for -h)
|
1416
|
+
if !@options[:analyze] && @file_path.nil? && !@args.include?('-h') && !@args.include?('--help')
|
1417
|
+
exit 1
|
540
1418
|
end
|
541
1419
|
end
|
542
1420
|
|
@@ -584,76 +1462,278 @@ module N2B
|
|
584
1462
|
end
|
585
1463
|
|
586
1464
|
def handle_editor_workflow(block, config, full_file_content)
|
587
|
-
|
1465
|
+
editor_command = config.dig('editor', 'command')
|
1466
|
+
editor_type = config.dig('editor', 'type')
|
1467
|
+
editor_configured = config.dig('editor', 'configured')
|
588
1468
|
|
589
|
-
|
590
|
-
|
591
|
-
|
1469
|
+
lines = File.readlines(@file_path, chomp: true)
|
1470
|
+
current_block_content_with_markers = lines[(block.start_line - 1)...block.end_line].join("\n")
|
1471
|
+
|
1472
|
+
if editor_configured && editor_command && editor_type == 'diff_tool'
|
1473
|
+
require 'tmpdir'
|
1474
|
+
Dir.mktmpdir("n2b_diff_") do |tmpdir|
|
1475
|
+
base_file_path = File.join(tmpdir, "base_#{File.basename(@file_path)}")
|
1476
|
+
remote_file_path = File.join(tmpdir, "remote_#{File.basename(@file_path)}")
|
1477
|
+
merged_file_path = File.join(tmpdir, "merged_#{File.basename(@file_path)}")
|
1478
|
+
|
1479
|
+
File.write(base_file_path, block.base_content)
|
1480
|
+
File.write(remote_file_path, block.incoming_content)
|
1481
|
+
|
1482
|
+
# Initial content for the merged file: LLM suggestion or current block with markers
|
1483
|
+
initial_merged_content = block.suggestion&.dig('merged_code')
|
1484
|
+
if initial_merged_content.nil? || initial_merged_content.strip.empty?
|
1485
|
+
initial_merged_content = current_block_content_with_markers
|
1486
|
+
end
|
1487
|
+
File.write(merged_file_path, initial_merged_content)
|
1488
|
+
|
1489
|
+
# Common pattern: tool base merged remote. Some tools might vary.
|
1490
|
+
# Example: meld uses local remote base --output output_file
|
1491
|
+
# For now, using a common sequence. Needs documentation for custom tools.
|
1492
|
+
# We assume the tool edits the `merged_file_path` (second argument) in place or uses it as output.
|
1493
|
+
full_diff_command = "#{editor_command} #{Shellwords.escape(base_file_path)} #{Shellwords.escape(merged_file_path)} #{Shellwords.escape(remote_file_path)}"
|
1494
|
+
puts "#{COLOR_BLUE}🔧 Launching diff tool: #{editor_command}...#{COLOR_RESET}"
|
1495
|
+
puts "#{COLOR_GRAY} Base: #{base_file_path}#{COLOR_RESET}"
|
1496
|
+
puts "#{COLOR_GRAY} Remote: #{remote_file_path}#{COLOR_RESET}"
|
1497
|
+
puts "#{COLOR_GRAY} Merged: #{merged_file_path} (edit this one)#{COLOR_RESET}"
|
1498
|
+
|
1499
|
+
system(full_diff_command)
|
1500
|
+
|
1501
|
+
puts "#{COLOR_BLUE}📁 Diff tool closed.#{COLOR_RESET}"
|
1502
|
+
merged_code_from_editor = File.read(merged_file_path)
|
1503
|
+
|
1504
|
+
# Check if the merged content is different from the initial content with markers
|
1505
|
+
# to avoid considering unchanged initial conflict markers as a resolution.
|
1506
|
+
if merged_code_from_editor.strip == current_block_content_with_markers.strip && merged_code_from_editor.include?('<<<<<<<')
|
1507
|
+
puts "#{COLOR_YELLOW}⚠️ It seems the conflict markers are still present. Did you resolve the conflict?#{COLOR_RESET}"
|
1508
|
+
end
|
592
1509
|
|
593
|
-
|
1510
|
+
print "#{COLOR_YELLOW}Did you resolve this conflict using the diff tool? [y/n]: #{COLOR_RESET}"
|
1511
|
+
response = $stdin.gets&.strip&.downcase
|
1512
|
+
if response == 'y'
|
1513
|
+
puts "#{COLOR_GREEN}✅ Conflict marked as resolved by user via diff tool#{COLOR_RESET}"
|
1514
|
+
return {
|
1515
|
+
resolved: true,
|
1516
|
+
merged_code: merged_code_from_editor,
|
1517
|
+
reason: "User resolved conflict with diff tool: #{editor_command}"
|
1518
|
+
}
|
1519
|
+
else
|
1520
|
+
puts "#{COLOR_BLUE}🔄 Conflict not marked as resolved. Continuing with AI assistance...#{COLOR_RESET}"
|
1521
|
+
# If user says 'n', we don't use the content from the diff tool as a resolution.
|
1522
|
+
# We might need to re-fetch LLM suggestion or just go back to menu.
|
1523
|
+
# For now, return resolved: false. The updated_content is not from the main file.
|
1524
|
+
return { resolved: false, updated_content: full_file_content } # original full_file_content
|
1525
|
+
end
|
1526
|
+
end # Tempdir is automatically removed
|
1527
|
+
else
|
1528
|
+
# Fallback to text editor or if editor is 'text_editor'
|
1529
|
+
editor_to_use = editor_command || detect_system_editor # Use configured or system editor
|
1530
|
+
|
1531
|
+
original_file_content_for_block_check = File.read(@file_path) # Before text editor opens it
|
1532
|
+
|
1533
|
+
puts "#{COLOR_BLUE}🔧 Opening #{@file_path} in editor (#{editor_to_use})...#{COLOR_RESET}"
|
1534
|
+
open_file_in_editor(@file_path, editor_to_use) # Pass specific editor
|
1535
|
+
puts "#{COLOR_BLUE}📁 Editor closed. Checking for changes...#{COLOR_RESET}"
|
1536
|
+
|
1537
|
+
current_file_content_after_edit = File.read(@file_path)
|
1538
|
+
|
1539
|
+
if file_changed?(original_file_content_for_block_check, current_file_content_after_edit)
|
1540
|
+
puts "#{COLOR_YELLOW}📝 File has been modified.#{COLOR_RESET}"
|
1541
|
+
print "#{COLOR_YELLOW}Did you resolve this conflict yourself in the editor? [y/n]: #{COLOR_RESET}"
|
1542
|
+
response = $stdin.gets&.strip&.downcase
|
1543
|
+
|
1544
|
+
if response == 'y'
|
1545
|
+
puts "#{COLOR_GREEN}✅ Conflict marked as resolved by user in text editor#{COLOR_RESET}"
|
1546
|
+
# Extract the changed block content
|
1547
|
+
# Re-read lines as they might have changed in number
|
1548
|
+
edited_lines = File.readlines(@file_path, chomp: true)
|
1549
|
+
# Heuristic: if lines were added/removed, block boundaries might shift.
|
1550
|
+
# For simplicity, we'll use original block's line numbers to extract,
|
1551
|
+
# but this might be inaccurate if user adds/removes many lines *outside* the conflict block.
|
1552
|
+
# A more robust way would be to re-parse or use markers if they exist.
|
1553
|
+
# For now, assume user edits primarily *within* the original start/end lines.
|
1554
|
+
# The number of lines in the resolved code could be different.
|
1555
|
+
# We need to ask the user to ensure the markers are gone.
|
1556
|
+
|
1557
|
+
# Let's get the content of the lines that corresponded to the original block.
|
1558
|
+
# This isn't perfect if the user adds/deletes lines *within* the block,
|
1559
|
+
# changing its length. The LLM's suggestion is for a block of a certain size.
|
1560
|
+
# For user resolution, they define the new block.
|
1561
|
+
# We need to get the content from start_line to (potentially new) end_line.
|
1562
|
+
# This is tricky. The simplest is to take the whole file, but that's not what merge tools do.
|
1563
|
+
# The contract is that the user removed the conflict markers.
|
1564
|
+
|
1565
|
+
# We will return the content of the file from the original start line
|
1566
|
+
# to an end line that reflects the number of lines in the manually merged code.
|
1567
|
+
# This is still tricky. Let's assume the user edited the block and the surrounding lines are stable.
|
1568
|
+
# The `resolve_block` method replaces `lines[(block.start_line-1)...block.end_line]`
|
1569
|
+
# So, the returned `merged_code` should be what replaces that segment.
|
1570
|
+
|
1571
|
+
# Simplest approach: user confirms resolution, we assume the relevant part of the file is the resolution.
|
1572
|
+
# We need to extract the content of the resolved block from current_file_content_after_edit
|
1573
|
+
# based on block.start_line and the *new* end_line of the resolved conflict.
|
1574
|
+
# This is hard without re-parsing.
|
1575
|
+
# A practical approach: The user resolved it. The file is now correct *at those lines*.
|
1576
|
+
# The `resolve_block` method will write the *entire* `lines` array back to the file.
|
1577
|
+
# If the user resolved it, the `lines` array (after their edit) IS the resolution for that part.
|
1578
|
+
# So, we need to give `resolve_block` the lines from the file that correspond to the original block markers.
|
1579
|
+
# This means the `merged_code` should be the content of the file from `block.start_line`
|
1580
|
+
# up to where the `block.end_line` *would* be after their edits.
|
1581
|
+
|
1582
|
+
# Let's refine: the user has edited the file. The section of the file
|
1583
|
+
# that previously contained the conflict markers (block.start_line to block.end_line)
|
1584
|
+
# now contains their resolution. We need to extract this segment.
|
1585
|
+
# The number of lines might have changed.
|
1586
|
+
# The `resolve_block` function will replace `lines[original_start_idx..original_end_idx]` with the new content.
|
1587
|
+
# So we must provide the exact lines that should go into that slice.
|
1588
|
+
|
1589
|
+
# We need to ask the user to confirm the new end line if it changed, or trust they know.
|
1590
|
+
# The simplest is to return the segment from the file from original start_line to original end_line,
|
1591
|
+
# assuming the user's changes fit there. This is too naive.
|
1592
|
+
|
1593
|
+
# If the user says 'y', the file is considered resolved in that region.
|
1594
|
+
# The `resolve_block` will then write the `lines` array (which is `current_file_content_after_edit.split("\n")`)
|
1595
|
+
# back to the file. The key is that `resolve_block` *already has* the full `lines` from the modified file
|
1596
|
+
# when it reconstructs the file if result[:accepted] is true.
|
1597
|
+
# So, the `merged_code` we return here is more for logging/consistency.
|
1598
|
+
# The critical part is that `lines` in `resolve_block` needs to be updated if the file was changed by the editor.
|
1599
|
+
|
1600
|
+
# The `resolve_block` method reads `lines = File.readlines(@file_path, chomp: true)` at the beginning.
|
1601
|
+
# If we edit the file here, `lines` in `resolve_block` becomes stale.
|
1602
|
+
# This means `handle_editor_workflow` must return the *new* full file content if it changed.
|
1603
|
+
# And the `merged_code` for the log should be the segment of the new file.
|
1604
|
+
|
1605
|
+
# Let's re-read the file and extract the relevant segment for the log.
|
1606
|
+
# The actual application of changes happens because `resolve_block` will use the modified `lines` array.
|
1607
|
+
# We need to estimate the new end_line. This is complex.
|
1608
|
+
# For now, let's just say "user resolved". The actual diff applied will be based on the whole file change.
|
1609
|
+
# The `merged_code` for logging can be a placeholder or the new content of the block.
|
1610
|
+
# Let's assume the user ensures the markers are gone.
|
1611
|
+
# The content of lines from block.start_line to block.end_line in the *new file* is their resolution.
|
1612
|
+
# The number of lines in this resolution can be different from the original block.
|
1613
|
+
# This is fine, as the `replacement` in `resolve_block` handles this.
|
1614
|
+
|
1615
|
+
# Simplification: if user says 'y', the code that will be used is the content
|
1616
|
+
# of the file from block.start_line to some new end_line.
|
1617
|
+
# The crucial part is that `resolve_block` needs to operate on the *modified* file content.
|
1618
|
+
# So, we should pass `current_file_content_after_edit` back up.
|
1619
|
+
# And for logging, extract the lines from `block.start_line` to `block.end_line` from this new content.
|
1620
|
+
# This assumes the user's resolution fits within the original line numbers, which is not always true.
|
1621
|
+
|
1622
|
+
# The most robust is to re-parse the file for conflict markers. If none are found in this region, it's resolved.
|
1623
|
+
# The "merged_code" would be the lines from the edited file that replaced the original conflict.
|
1624
|
+
|
1625
|
+
# Let's assume the user has resolved the conflict markers from `block.start_line` to `block.end_line`.
|
1626
|
+
# The content of these lines in `current_file_content_after_edit` is the resolution.
|
1627
|
+
# The number of lines of this resolution might be different.
|
1628
|
+
# The `resolve_block` needs to replace `lines[(block.start_line-1)...block.end_line]`
|
1629
|
+
# The `merged_code` should be this new segment.
|
1630
|
+
|
1631
|
+
# For now, let `merged_code` be a conceptual value. The `resolve_block` loop needs to use the new file content.
|
1632
|
+
# The key is `updated_content` for the main loop, and `merged_code` for logging.
|
1633
|
+
|
1634
|
+
# The `resolve_block` method needs to use the content of the file *after* the edit.
|
1635
|
+
# The current structure of `resolve_block` re-reads `lines` only if `request_merge_with_spinner` is called again.
|
1636
|
+
# This needs adjustment.
|
1637
|
+
|
1638
|
+
# For now, if user says 'y':
|
1639
|
+
# 1. The `merged_code` will be what's in the file from `block.start_line` to `block.end_line` (original numbering).
|
1640
|
+
# This is imperfect for logging if lines were added/removed.
|
1641
|
+
# 2. The `resolve_block` loop must use `current_file_content_after_edit` for its `lines` variable.
|
1642
|
+
# This is the most important part for correctness.
|
1643
|
+
|
1644
|
+
# Let's return the segment from the modified file for `merged_code`.
|
1645
|
+
# This is still tricky because `block.end_line` is from the original parse.
|
1646
|
+
# If user deleted lines, `block.end_line` might be out of bounds for `edited_lines`.
|
1647
|
+
# If user added lines, we wouldn't capture all of it.
|
1648
|
+
|
1649
|
+
# Simplest for now: the user resolved it. The merged_code for logging can be a placeholder.
|
1650
|
+
# The main thing is that `resolve_block` now operates on `current_file_content_after_edit`.
|
1651
|
+
# The subtask asks for `merged_code` to be `<content_from_editor_or_file>`.
|
1652
|
+
# This means the content of the resolved block.
|
1653
|
+
|
1654
|
+
# Let's try to extract the content from the edited file using original line numbers as a guide.
|
1655
|
+
# This is a known limitation. A better way would be for user to indicate new block end.
|
1656
|
+
resolved_segment = edited_lines[(block.start_line - 1)..[block.end_line - 1]].join("\n") rescue "User resolved - content not easily extracted due to line changes"
|
1657
|
+
if edited_lines.slice((block.start_line-1)...(block.end_line)).join("\n").include?("<<<<<<<")
|
1658
|
+
puts "#{COLOR_YELLOW}⚠️ Conflict markers seem to still be present in the edited file. Please ensure they are removed for proper resolution.#{COLOR_RESET}"
|
1659
|
+
end
|
594
1660
|
|
595
|
-
if file_changed?(original_content, current_content)
|
596
|
-
puts "#{COLOR_YELLOW}📝 File has been modified.#{COLOR_RESET}"
|
597
|
-
print "#{COLOR_YELLOW}Did you resolve this conflict yourself? [y/n]: #{COLOR_RESET}"
|
598
|
-
response = $stdin.gets&.strip&.downcase
|
599
1661
|
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
1662
|
+
return {
|
1663
|
+
resolved: true,
|
1664
|
+
merged_code: resolved_segment, # Content from the file for the resolved block
|
1665
|
+
reason: "User resolved conflict in text editor: #{editor_to_use}",
|
1666
|
+
updated_content: current_file_content_after_edit # Pass back the full content
|
1667
|
+
}
|
1668
|
+
else
|
1669
|
+
puts "#{COLOR_BLUE}🔄 Conflict not marked as resolved. Continuing with AI assistance...#{COLOR_RESET}"
|
1670
|
+
return {
|
1671
|
+
resolved: false,
|
1672
|
+
updated_content: current_file_content_after_edit # Pass back the full content
|
1673
|
+
}
|
1674
|
+
end
|
607
1675
|
else
|
608
|
-
puts "#{
|
609
|
-
return {
|
610
|
-
resolved: false,
|
611
|
-
updated_content: current_content
|
612
|
-
}
|
1676
|
+
puts "#{COLOR_GRAY}📋 No changes detected in the file. Continuing...#{COLOR_RESET}"
|
1677
|
+
return { resolved: false, updated_content: nil } # No changes, so original full_file_content is still valid
|
613
1678
|
end
|
614
|
-
else
|
615
|
-
puts "#{COLOR_GRAY}📋 No changes detected. Continuing...#{COLOR_RESET}"
|
616
|
-
return {resolved: false, updated_content: nil}
|
617
1679
|
end
|
618
1680
|
end
|
619
1681
|
|
620
|
-
def detect_editor
|
621
|
-
ENV['EDITOR'] || ENV['VISUAL'] || detect_system_editor
|
622
|
-
end
|
623
|
-
|
624
1682
|
def detect_system_editor
|
1683
|
+
# This is the ultimate fallback if no configuration is set.
|
1684
|
+
# The ENV['EDITOR'] || ENV['VISUAL'] check should be done by the caller if preferred before this.
|
625
1685
|
case RbConfig::CONFIG['host_os']
|
626
1686
|
when /darwin|mac os/
|
627
|
-
'open'
|
1687
|
+
'open' # Typically non-blocking, might need different handling or user awareness.
|
628
1688
|
when /linux/
|
629
|
-
|
1689
|
+
# Prefer common user-friendly editors if available, then vi as fallback.
|
1690
|
+
# This simple version just picks one. `command_exists?` could be used here.
|
1691
|
+
ENV['EDITOR'] || ENV['VISUAL'] || 'nano' # or 'vi'
|
630
1692
|
when /mswin|mingw/
|
631
|
-
'notepad'
|
1693
|
+
ENV['EDITOR'] || ENV['VISUAL'] || 'notepad'
|
632
1694
|
else
|
633
|
-
'vi'
|
1695
|
+
ENV['EDITOR'] || ENV['VISUAL'] || 'vi' # vi is a common default on Unix-like systems
|
634
1696
|
end
|
635
1697
|
end
|
636
1698
|
|
637
|
-
def open_file_in_editor(file_path)
|
638
|
-
editor
|
1699
|
+
def open_file_in_editor(file_path, editor_command = nil)
|
1700
|
+
# If no specific editor_command is passed, try configured editor, then system fallbacks.
|
1701
|
+
# This method is now simplified as the decision of *which* editor (configured vs fallback)
|
1702
|
+
# is made in handle_editor_workflow. This method just executes it.
|
1703
|
+
# However, the original call from resolve_block (before this change) did not pass editor_command.
|
1704
|
+
# So, if editor_command is nil, we should still try to get it from config or fallback.
|
639
1705
|
|
1706
|
+
effective_editor = editor_command # Use passed command if available
|
1707
|
+
|
1708
|
+
if effective_editor.nil?
|
1709
|
+
config = get_config(reconfigure: false, advanced_flow: false) # Ensure config is loaded
|
1710
|
+
effective_editor = config.dig('editor', 'command') if config.dig('editor', 'configured')
|
1711
|
+
effective_editor ||= detect_system_editor # Ultimate fallback
|
1712
|
+
end
|
1713
|
+
|
1714
|
+
puts "#{COLOR_GRAY}Attempting to open with: #{effective_editor} #{Shellwords.escape(file_path)}#{COLOR_RESET}"
|
640
1715
|
begin
|
641
|
-
|
642
|
-
|
643
|
-
|
644
|
-
result
|
645
|
-
|
646
|
-
|
647
|
-
puts "#{
|
1716
|
+
if RbConfig::CONFIG['host_os'] =~ /darwin|mac os/ && effective_editor == 'open'
|
1717
|
+
# 'open' is non-blocking. This is fine.
|
1718
|
+
result = system("open #{Shellwords.escape(file_path)}")
|
1719
|
+
unless result
|
1720
|
+
# `system` returns true if command found and exited with 0, false otherwise for `open`.
|
1721
|
+
# It returns nil if command execution fails.
|
1722
|
+
puts "#{COLOR_YELLOW}⚠️ 'open' command might have failed or file opened in background. Please check manually.#{COLOR_RESET}"
|
648
1723
|
end
|
1724
|
+
# For 'open', we don't wait. User needs to manually come back.
|
1725
|
+
# Consider adding a prompt "Press Enter after closing the editor..." for non-blocking editors.
|
1726
|
+
# For now, keeping it simple.
|
649
1727
|
else
|
650
|
-
#
|
651
|
-
|
652
|
-
system("#{editor} #{Shellwords.escape(file_path)}")
|
1728
|
+
# For most terminal editors, system() will block until the editor is closed.
|
1729
|
+
system("#{effective_editor} #{Shellwords.escape(file_path)}")
|
653
1730
|
end
|
654
|
-
rescue => e
|
655
|
-
puts "#{COLOR_RED}❌ Failed to open editor: #{e.message}#{COLOR_RESET}"
|
656
|
-
puts "#{COLOR_YELLOW}💡
|
1731
|
+
rescue StandardError => e
|
1732
|
+
puts "#{COLOR_RED}❌ Failed to open editor '#{effective_editor}': #{e.message}#{COLOR_RESET}"
|
1733
|
+
puts "#{COLOR_YELLOW}💡 Please ensure your configured editor is correct or set your EDITOR environment variable.#{COLOR_RESET}"
|
1734
|
+
puts "#{COLOR_BLUE}You may need to open #{file_path} manually in your preferred editor to make changes.#{COLOR_RESET}"
|
1735
|
+
print "#{COLOR_YELLOW}Press Enter to continue after manually editing (if you choose to do so)...#{COLOR_RESET}"
|
1736
|
+
$stdin.gets # Pause to allow manual editing
|
657
1737
|
end
|
658
1738
|
end
|
659
1739
|
|
@@ -690,5 +1770,560 @@ module N2B
|
|
690
1770
|
puts "#{COLOR_GRAY}⚠️ Could not save debug info: #{e.message}#{COLOR_RESET}"
|
691
1771
|
end
|
692
1772
|
end
|
1773
|
+
|
1774
|
+
def generate_merge_log_html(log_entries, timestamp)
|
1775
|
+
git_info = extract_git_info
|
1776
|
+
|
1777
|
+
html = <<~HTML
|
1778
|
+
<!DOCTYPE html>
|
1779
|
+
<html lang="en">
|
1780
|
+
<head>
|
1781
|
+
<meta charset="UTF-8">
|
1782
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
1783
|
+
<title>N2B Merge Log - #{@file_path} - #{timestamp}</title>
|
1784
|
+
<style>
|
1785
|
+
body {
|
1786
|
+
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
1787
|
+
margin: 0;
|
1788
|
+
padding: 20px;
|
1789
|
+
background-color: #f8f9fa;
|
1790
|
+
color: #333;
|
1791
|
+
line-height: 1.6;
|
1792
|
+
}
|
1793
|
+
.header {
|
1794
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
1795
|
+
color: white;
|
1796
|
+
padding: 20px;
|
1797
|
+
border-radius: 8px;
|
1798
|
+
margin-bottom: 20px;
|
1799
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
1800
|
+
}
|
1801
|
+
.header h1 {
|
1802
|
+
margin: 0 0 10px 0;
|
1803
|
+
font-size: 24px;
|
1804
|
+
}
|
1805
|
+
.header .meta {
|
1806
|
+
opacity: 0.9;
|
1807
|
+
font-size: 14px;
|
1808
|
+
}
|
1809
|
+
.conflict-container {
|
1810
|
+
background: white;
|
1811
|
+
border-radius: 8px;
|
1812
|
+
margin-bottom: 20px;
|
1813
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
1814
|
+
overflow: hidden;
|
1815
|
+
}
|
1816
|
+
.conflict-header {
|
1817
|
+
background-color: #e9ecef;
|
1818
|
+
padding: 15px;
|
1819
|
+
border-bottom: 1px solid #dee2e6;
|
1820
|
+
font-weight: bold;
|
1821
|
+
color: #495057;
|
1822
|
+
}
|
1823
|
+
.conflict-table {
|
1824
|
+
width: 100%;
|
1825
|
+
border-collapse: collapse;
|
1826
|
+
}
|
1827
|
+
.conflict-table th {
|
1828
|
+
background-color: #f8f9fa;
|
1829
|
+
padding: 12px;
|
1830
|
+
text-align: left;
|
1831
|
+
font-weight: 600;
|
1832
|
+
border-bottom: 2px solid #dee2e6;
|
1833
|
+
color: #495057;
|
1834
|
+
}
|
1835
|
+
.conflict-table td {
|
1836
|
+
padding: 12px;
|
1837
|
+
border-bottom: 1px solid #e9ecef;
|
1838
|
+
vertical-align: top;
|
1839
|
+
}
|
1840
|
+
.code-block {
|
1841
|
+
background-color: #f8f9fa;
|
1842
|
+
border: 1px solid #e9ecef;
|
1843
|
+
border-radius: 4px;
|
1844
|
+
padding: 10px;
|
1845
|
+
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
1846
|
+
font-size: 13px;
|
1847
|
+
white-space: pre-wrap;
|
1848
|
+
overflow-x: auto;
|
1849
|
+
max-height: 300px;
|
1850
|
+
overflow-y: auto;
|
1851
|
+
}
|
1852
|
+
.base-code { border-left: 4px solid #dc3545; }
|
1853
|
+
.incoming-code { border-left: 4px solid #007bff; }
|
1854
|
+
.resolution-code { border-left: 4px solid #28a745; }
|
1855
|
+
.method-badge {
|
1856
|
+
display: inline-block;
|
1857
|
+
padding: 4px 8px;
|
1858
|
+
border-radius: 12px;
|
1859
|
+
font-size: 12px;
|
1860
|
+
font-weight: 500;
|
1861
|
+
text-transform: uppercase;
|
1862
|
+
}
|
1863
|
+
.method-llm { background-color: #e3f2fd; color: #1976d2; }
|
1864
|
+
.method-manual { background-color: #fff3e0; color: #f57c00; }
|
1865
|
+
.method-skip { background-color: #fce4ec; color: #c2185b; }
|
1866
|
+
.method-abort { background-color: #ffebee; color: #d32f2f; }
|
1867
|
+
.footer {
|
1868
|
+
text-align: center;
|
1869
|
+
margin-top: 30px;
|
1870
|
+
padding: 20px;
|
1871
|
+
color: #6c757d;
|
1872
|
+
font-size: 14px;
|
1873
|
+
}
|
1874
|
+
.stats {
|
1875
|
+
display: flex;
|
1876
|
+
gap: 20px;
|
1877
|
+
margin-top: 10px;
|
1878
|
+
}
|
1879
|
+
.stat-item {
|
1880
|
+
background: rgba(255, 255, 255, 0.2);
|
1881
|
+
padding: 8px 12px;
|
1882
|
+
border-radius: 4px;
|
1883
|
+
}
|
1884
|
+
</style>
|
1885
|
+
</head>
|
1886
|
+
<body>
|
1887
|
+
<div class="header">
|
1888
|
+
<h1>🔀 N2B Merge Resolution Log</h1>
|
1889
|
+
<div class="meta">
|
1890
|
+
<strong>File:</strong> #{@file_path}<br>
|
1891
|
+
<strong>Timestamp:</strong> #{Time.now.strftime('%Y-%m-%d %H:%M:%S UTC')}<br>
|
1892
|
+
<strong>Branch:</strong> #{git_info[:branch]}<br>
|
1893
|
+
<strong>Total Conflicts:</strong> #{log_entries.length}
|
1894
|
+
</div>
|
1895
|
+
<div class="stats">
|
1896
|
+
<div class="stat-item">
|
1897
|
+
<strong>Resolved:</strong> #{log_entries.count { |e| e[:action] == 'accepted' }}
|
1898
|
+
</div>
|
1899
|
+
<div class="stat-item">
|
1900
|
+
<strong>Skipped:</strong> #{log_entries.count { |e| e[:action] == 'skipped' }}
|
1901
|
+
</div>
|
1902
|
+
<div class="stat-item">
|
1903
|
+
<strong>Aborted:</strong> #{log_entries.count { |e| e[:action] == 'aborted' }}
|
1904
|
+
</div>
|
1905
|
+
</div>
|
1906
|
+
</div>
|
1907
|
+
HTML
|
1908
|
+
|
1909
|
+
log_entries.each_with_index do |entry, index|
|
1910
|
+
conflict_number = index + 1
|
1911
|
+
method_class = case entry[:resolution_method]
|
1912
|
+
when /llm|ai|suggested/i then 'method-llm'
|
1913
|
+
when /manual|user|edit/i then 'method-manual'
|
1914
|
+
when /skip/i then 'method-skip'
|
1915
|
+
when /abort/i then 'method-abort'
|
1916
|
+
else 'method-llm'
|
1917
|
+
end
|
1918
|
+
|
1919
|
+
html += <<~CONFLICT_HTML
|
1920
|
+
<div class="conflict-container">
|
1921
|
+
<div class="conflict-header">
|
1922
|
+
Conflict ##{conflict_number} - Lines #{entry[:start_line]}-#{entry[:end_line]}
|
1923
|
+
<span class="method-badge #{method_class}">#{entry[:resolution_method]}</span>
|
1924
|
+
</div>
|
1925
|
+
<table class="conflict-table">
|
1926
|
+
<thead>
|
1927
|
+
<tr>
|
1928
|
+
<th style="width: 25%">Base Branch Code</th>
|
1929
|
+
<th style="width: 25%">Incoming Branch Code</th>
|
1930
|
+
<th style="width: 25%">Final Resolution</th>
|
1931
|
+
<th style="width: 25%">Resolution Details</th>
|
1932
|
+
</tr>
|
1933
|
+
</thead>
|
1934
|
+
<tbody>
|
1935
|
+
<tr>
|
1936
|
+
<td>
|
1937
|
+
<div class="code-block base-code">#{escape_html(entry[:base_content] || 'N/A')}</div>
|
1938
|
+
</td>
|
1939
|
+
<td>
|
1940
|
+
<div class="code-block incoming-code">#{escape_html(entry[:incoming_content] || 'N/A')}</div>
|
1941
|
+
</td>
|
1942
|
+
<td>
|
1943
|
+
<div class="code-block resolution-code">#{escape_html(entry[:resolved_content] || 'N/A')}</div>
|
1944
|
+
</td>
|
1945
|
+
<td>
|
1946
|
+
<div class="code-block">
|
1947
|
+
<strong>Method:</strong> #{escape_html(entry[:resolution_method])}<br>
|
1948
|
+
<strong>Action:</strong> #{escape_html(entry[:action])}<br>
|
1949
|
+
<strong>Time:</strong> #{entry[:timestamp]}<br><br>
|
1950
|
+
#{entry[:llm_suggestion] ? "<strong>LLM Analysis:</strong><br>#{escape_html(entry[:llm_suggestion])}" : ''}
|
1951
|
+
</div>
|
1952
|
+
</td>
|
1953
|
+
</tr>
|
1954
|
+
</tbody>
|
1955
|
+
</table>
|
1956
|
+
</div>
|
1957
|
+
CONFLICT_HTML
|
1958
|
+
end
|
1959
|
+
|
1960
|
+
html += <<~FOOTER_HTML
|
1961
|
+
<div class="footer">
|
1962
|
+
Generated by N2B v#{N2B::VERSION} - AI-Powered Merge Conflict Resolution<br>
|
1963
|
+
<small>This log contains the complete history of merge conflict resolutions for audit and review purposes.</small>
|
1964
|
+
</div>
|
1965
|
+
</body>
|
1966
|
+
</html>
|
1967
|
+
FOOTER_HTML
|
1968
|
+
|
1969
|
+
html
|
1970
|
+
end
|
1971
|
+
|
1972
|
+
def escape_html(text)
|
1973
|
+
return '' if text.nil?
|
1974
|
+
text.to_s
|
1975
|
+
.gsub('&', '&')
|
1976
|
+
.gsub('<', '<')
|
1977
|
+
.gsub('>', '>')
|
1978
|
+
.gsub('"', '"')
|
1979
|
+
.gsub("'", ''')
|
1980
|
+
end
|
1981
|
+
|
1982
|
+
def extract_git_info
|
1983
|
+
branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip rescue 'unknown'
|
1984
|
+
{
|
1985
|
+
branch: branch.empty? ? 'unknown' : branch
|
1986
|
+
}
|
1987
|
+
end
|
1988
|
+
|
1989
|
+
def determine_resolution_method(result)
|
1990
|
+
return 'Aborted' if result[:abort]
|
1991
|
+
return 'Manual Edit' if result[:reason]&.include?('manually resolved')
|
1992
|
+
return 'Manual Choice' if result[:reason]&.include?('Manually selected')
|
1993
|
+
return 'Skipped' if !result[:accepted] && !result[:abort]
|
1994
|
+
return 'LLM Suggestion' if result[:accepted] && result[:reason]
|
1995
|
+
'Unknown'
|
1996
|
+
end
|
1997
|
+
|
1998
|
+
def determine_action(result)
|
1999
|
+
return 'aborted' if result[:abort]
|
2000
|
+
return 'accepted' if result[:accepted]
|
2001
|
+
return 'skipped'
|
2002
|
+
end
|
2003
|
+
|
2004
|
+
private # Ensure all subsequent methods are private unless specified
|
2005
|
+
|
2006
|
+
def get_vcs_type_for_file_operations
|
2007
|
+
# Leverages existing get_vcs_type but more for file content retrieval context
|
2008
|
+
get_vcs_type
|
2009
|
+
end
|
2010
|
+
|
2011
|
+
def convert_label_to_revision(label, vcs_type, side)
|
2012
|
+
# Convert conflict marker labels to actual VCS revision identifiers
|
2013
|
+
case vcs_type
|
2014
|
+
when :git
|
2015
|
+
case label.downcase
|
2016
|
+
when /head|working.*copy|current/
|
2017
|
+
'HEAD'
|
2018
|
+
when /merge.*rev|incoming|branch/
|
2019
|
+
'MERGE_HEAD'
|
2020
|
+
else
|
2021
|
+
# If it's already a valid Git revision, use it as-is
|
2022
|
+
label
|
2023
|
+
end
|
2024
|
+
when :hg
|
2025
|
+
case label.downcase
|
2026
|
+
when /working.*copy|current/
|
2027
|
+
'.' # Current working directory parent
|
2028
|
+
when /merge.*rev|incoming|branch/
|
2029
|
+
'p2()' # Second parent (incoming branch)
|
2030
|
+
else
|
2031
|
+
# If it's already a valid Mercurial revision, use it as-is
|
2032
|
+
label
|
2033
|
+
end
|
2034
|
+
else
|
2035
|
+
label
|
2036
|
+
end
|
2037
|
+
end
|
2038
|
+
|
2039
|
+
def get_file_content_from_vcs(label, file_path, vcs_type)
|
2040
|
+
# Note: file_path is the path in the working directory.
|
2041
|
+
# VCS commands often need path relative to repo root if not run from root.
|
2042
|
+
# Assuming execution from repo root or that file_path is appropriately relative.
|
2043
|
+
# Pathname can help make it relative if needed:
|
2044
|
+
# relative_file_path = Pathname.new(File.absolute_path(file_path)).relative_path_from(Pathname.new(Dir.pwd)).to_s
|
2045
|
+
|
2046
|
+
# A simpler approach for `git show` is that it usually works with paths from repo root.
|
2047
|
+
# If @file_path is already relative to repo root or absolute, it might just work.
|
2048
|
+
# For robustness, ensuring it's relative to repo root is better.
|
2049
|
+
# However, current `execute_vcs_diff` uses `Dir.pwd`, implying commands are run from current dir.
|
2050
|
+
# Let's assume file_path as given is suitable for now.
|
2051
|
+
|
2052
|
+
command = case vcs_type
|
2053
|
+
when :git
|
2054
|
+
# For git show <commit-ish>:<path>, path is usually from repo root.
|
2055
|
+
# If @file_path is not from repo root, this might need adjustment.
|
2056
|
+
# Let's assume @file_path is correctly specified for this context.
|
2057
|
+
"git show #{Shellwords.escape(label)}:#{Shellwords.escape(file_path)}"
|
2058
|
+
when :hg
|
2059
|
+
"hg cat -r #{Shellwords.escape(label)} #{Shellwords.escape(file_path)}"
|
2060
|
+
else
|
2061
|
+
nil
|
2062
|
+
end
|
2063
|
+
|
2064
|
+
return nil unless command
|
2065
|
+
|
2066
|
+
begin
|
2067
|
+
# Timeout might be needed for very large files or slow VCS.
|
2068
|
+
content = `#{command}` # Using backticks captures stdout
|
2069
|
+
# Check $? for command success.
|
2070
|
+
# `git show` returns 0 on success, non-zero otherwise (e.g. 128 if path not found).
|
2071
|
+
# `hg cat` also returns 0 on success.
|
2072
|
+
return content if $?.success?
|
2073
|
+
|
2074
|
+
# If command failed, log a warning but don't necessarily halt everything.
|
2075
|
+
# The preview will just show empty for that panel.
|
2076
|
+
puts "#{COLOR_YELLOW}Warning: VCS command '#{command}' failed or returned no content. Exit status: #{$?.exitstatus}.#{COLOR_RESET}"
|
2077
|
+
nil
|
2078
|
+
rescue StandardError => e
|
2079
|
+
puts "#{COLOR_YELLOW}Warning: Could not fetch content for '#{file_path}' from VCS label '#{label}': #{e.message}#{COLOR_RESET}"
|
2080
|
+
nil
|
2081
|
+
end
|
2082
|
+
end
|
2083
|
+
|
2084
|
+
def apply_hunk_to_full_content(original_full_content_with_markers, conflict_block_details, resolved_hunk_text)
|
2085
|
+
return original_full_content_with_markers if resolved_hunk_text.nil?
|
2086
|
+
|
2087
|
+
lines = original_full_content_with_markers.lines.map(&:chomp)
|
2088
|
+
# Convert 1-based line numbers from block_details to 0-based array indices
|
2089
|
+
start_idx = conflict_block_details.start_line - 1
|
2090
|
+
end_idx = conflict_block_details.end_line - 1
|
2091
|
+
|
2092
|
+
# Basic validation of indices
|
2093
|
+
if start_idx < 0 || start_idx >= lines.length || end_idx < start_idx || end_idx >= lines.length
|
2094
|
+
# This case should ideally not happen if block_details are correct
|
2095
|
+
# Return original content or handle error appropriately
|
2096
|
+
# For preview, it's safer to show the original content with markers if hunk application is problematic
|
2097
|
+
return original_full_content_with_markers
|
2098
|
+
end
|
2099
|
+
|
2100
|
+
hunk_lines = resolved_hunk_text.lines.map(&:chomp)
|
2101
|
+
|
2102
|
+
new_content_lines = []
|
2103
|
+
new_content_lines.concat(lines[0...start_idx]) if start_idx > 0 # Lines before the conflict block
|
2104
|
+
new_content_lines.concat(hunk_lines) # The resolved hunk
|
2105
|
+
new_content_lines.concat(lines[(end_idx + 1)..-1]) if (end_idx + 1) < lines.length # Lines after the conflict block
|
2106
|
+
|
2107
|
+
new_content_lines.join("\n")
|
2108
|
+
end
|
2109
|
+
|
2110
|
+
# --- HTML Preview Generation ---
|
2111
|
+
|
2112
|
+
# private (already established)
|
2113
|
+
|
2114
|
+
def get_language_class(file_path)
|
2115
|
+
ext = File.extname(file_path).downcase
|
2116
|
+
case ext
|
2117
|
+
when '.rb' then 'ruby'
|
2118
|
+
when '.js' then 'javascript'
|
2119
|
+
when '.py' then 'python'
|
2120
|
+
when '.java' then 'java'
|
2121
|
+
when '.c', '.h', '.cpp', '.hpp' then 'cpp'
|
2122
|
+
when '.cs' then 'csharp'
|
2123
|
+
when '.go' then 'go'
|
2124
|
+
when '.php' then 'php'
|
2125
|
+
when '.ts' then 'typescript'
|
2126
|
+
when '.swift' then 'swift'
|
2127
|
+
when '.kt', '.kts' then 'kotlin'
|
2128
|
+
when '.rs' then 'rust'
|
2129
|
+
when '.scala' then 'scala'
|
2130
|
+
when '.pl' then 'perl'
|
2131
|
+
when '.pm' then 'perl'
|
2132
|
+
when '.sh' then 'bash'
|
2133
|
+
when '.html', '.htm', '.xhtml', '.xml' then 'xml' # Highlight.js uses 'xml' for HTML
|
2134
|
+
when '.css' then 'css'
|
2135
|
+
when '.json' then 'json'
|
2136
|
+
when '.yml', '.yaml' then 'yaml'
|
2137
|
+
when '.md', '.markdown' then 'markdown'
|
2138
|
+
else
|
2139
|
+
'' # Let Highlight.js auto-detect or default to plain text
|
2140
|
+
end
|
2141
|
+
end
|
2142
|
+
|
2143
|
+
def find_sub_content_lines(full_content, sub_content)
|
2144
|
+
return nil if sub_content.nil? || sub_content.empty?
|
2145
|
+
full_lines = full_content.lines.map(&:chomp)
|
2146
|
+
sub_lines = sub_content.lines.map(&:chomp)
|
2147
|
+
|
2148
|
+
return nil if sub_lines.empty?
|
2149
|
+
|
2150
|
+
full_lines.each_with_index do |_, index|
|
2151
|
+
match = true
|
2152
|
+
sub_lines.each_with_index do |sub_line_content, sub_index|
|
2153
|
+
unless full_lines[index + sub_index] == sub_line_content
|
2154
|
+
match = false
|
2155
|
+
break
|
2156
|
+
end
|
2157
|
+
end
|
2158
|
+
return { start: index + 1, end: index + sub_lines.length } if match # 1-based line numbers
|
2159
|
+
end
|
2160
|
+
nil
|
2161
|
+
end
|
2162
|
+
|
2163
|
+
def generate_conflict_preview_html(block_details, base_content_full, incoming_content_full, current_resolution_content_full, base_branch_name, incoming_branch_name, file_path, llm_suggestion = nil)
|
2164
|
+
require 'cgi' # For CGI.escapeHTML
|
2165
|
+
require 'fileutils' # For FileUtils.mkdir_p
|
2166
|
+
require 'shellwords' # For Shellwords.escape, already used elsewhere but good to have contextually
|
2167
|
+
require 'rbconfig' # For RbConfig::CONFIG
|
2168
|
+
|
2169
|
+
lang_class = get_language_class(file_path)
|
2170
|
+
|
2171
|
+
# Find line numbers for highlighting
|
2172
|
+
# These are line numbers within their respective full_content strings (1-based)
|
2173
|
+
base_highlight_lines = find_sub_content_lines(base_content_full, block_details.base_content)
|
2174
|
+
incoming_highlight_lines = find_sub_content_lines(incoming_content_full, block_details.incoming_content)
|
2175
|
+
|
2176
|
+
# For resolution, highlight the conflict area within the full resolution content
|
2177
|
+
# The current_resolution_content_full already contains the resolved content
|
2178
|
+
# We'll highlight the same line range as the original conflict
|
2179
|
+
resolution_highlight_lines = { start: block_details.start_line, end: block_details.end_line }
|
2180
|
+
|
2181
|
+
html_content = StringIO.new
|
2182
|
+
html_content << "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
|
2183
|
+
html_content << " <meta charset=\"UTF-8\">\n"
|
2184
|
+
html_content << " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
|
2185
|
+
html_content << " <title>Conflict Preview: #{CGI.escapeHTML(File.basename(file_path))}</title>\n"
|
2186
|
+
html_content << " <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css\">\n"
|
2187
|
+
html_content << " <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n"
|
2188
|
+
# Optional: specific languages if needed, e.g.,
|
2189
|
+
# html_content << " <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/#{lang_class}.min.js\"></script>\n" if lang_class && !lang_class.empty?
|
2190
|
+
|
2191
|
+
html_content << " <style>\n"
|
2192
|
+
html_content << " body { font-family: sans-serif; margin: 0; display: flex; flex-direction: column; height: 100vh; }\n"
|
2193
|
+
html_content << " .header { padding: 10px; background-color: #f0f0f0; border-bottom: 1px solid #ccc; text-align: center; }\n"
|
2194
|
+
html_content << " .header h2 { margin: 0; }\n"
|
2195
|
+
html_content << " .llm-message { padding: 10px; background-color: #e8f4fd; border-bottom: 1px solid #ccc; margin: 0; }\n"
|
2196
|
+
html_content << " .llm-message h3 { margin: 0 0 5px 0; color: #1976d2; font-size: 0.9em; }\n"
|
2197
|
+
html_content << " .llm-message p { margin: 0; color: #424242; font-size: 0.85em; line-height: 1.3; }\n"
|
2198
|
+
html_content << " .columns-container { display: flex; flex: 1; overflow: hidden; }\n"
|
2199
|
+
html_content << " .column { flex: 1; padding: 0; border-left: 1px solid #ccc; overflow-y: auto; display: flex; flex-direction: column; }\n"
|
2200
|
+
html_content << " .column:first-child { border-left: none; }\n"
|
2201
|
+
html_content << " .column h3 { background-color: #e0e0e0; padding: 8px 10px; margin: 0; border-bottom: 1px solid #ccc; text-align: center; font-size: 1em; }\n"
|
2202
|
+
html_content << " .code-container { flex: 1; overflow-y: auto; position: relative; }\n"
|
2203
|
+
html_content << " pre { margin: 0; padding: 0; height: 100%; }\n"
|
2204
|
+
html_content << " code { display: block; padding: 10px 10px 10px 60px; font-family: 'SF Mono', Monaco, Inconsolata, 'Fira Code', monospace; font-size: 0.85em; line-height: 1.4em; }\n"
|
2205
|
+
html_content << " .line { display: block; position: relative; }\n"
|
2206
|
+
html_content << " .line-number { position: absolute; left: 0; width: 50px; padding-right: 10px; text-align: right; color: #999; user-select: none; font-size: 0.8em; background-color: #f8f8f8; border-right: 1px solid #e0e0e0; }\n"
|
2207
|
+
html_content << " .conflict-lines-base { background-color: #ffebee; border-left: 3px solid #f44336; }\n"
|
2208
|
+
html_content << " .conflict-lines-incoming { background-color: #e3f2fd; border-left: 3px solid #2196f3; }\n"
|
2209
|
+
html_content << " .conflict-lines-resolution { background-color: #e8f5e9; border-left: 3px solid #4caf50; }\n"
|
2210
|
+
html_content << " @media (max-width: 768px) { .columns-container { flex-direction: column; } .column { border-left: none; border-top: 1px solid #ccc;} }\n"
|
2211
|
+
html_content << " </style>\n</head>\n<body>\n"
|
2212
|
+
|
2213
|
+
html_content << " <div class=\"header\"><h2>Conflict Preview: #{CGI.escapeHTML(file_path)}</h2></div>\n"
|
2214
|
+
|
2215
|
+
# Add LLM message section if available
|
2216
|
+
if llm_suggestion && llm_suggestion['reason']
|
2217
|
+
html_content << " <div class=\"llm-message\">\n"
|
2218
|
+
html_content << " <h3>🤖 AI Analysis & Suggestion</h3>\n"
|
2219
|
+
html_content << " <p>#{CGI.escapeHTML(llm_suggestion['reason'])}</p>\n"
|
2220
|
+
html_content << " </div>\n"
|
2221
|
+
end
|
2222
|
+
|
2223
|
+
html_content << " <div class=\"columns-container\">\n"
|
2224
|
+
|
2225
|
+
# Helper to generate HTML for one column
|
2226
|
+
generate_column_html = lambda do |title, full_code, highlight_info, highlight_class_suffix|
|
2227
|
+
html_content << " <div class=\"column\">\n"
|
2228
|
+
html_content << " <h3>#{CGI.escapeHTML(title)}</h3>\n"
|
2229
|
+
html_content << " <div class=\"code-container\">\n" # Wrapper for scrolling
|
2230
|
+
html_content << " <pre><code class=\"#{lang_class}\">\n"
|
2231
|
+
|
2232
|
+
full_code.lines.each_with_index do |line_text, index|
|
2233
|
+
line_number = index + 1
|
2234
|
+
line_class = "line"
|
2235
|
+
if highlight_info && line_number >= highlight_info[:start] && line_number <= highlight_info[:end]
|
2236
|
+
line_class += " conflict-lines conflict-lines-#{highlight_class_suffix}"
|
2237
|
+
end
|
2238
|
+
html_content << "<span class=\"#{line_class}\"><span class=\"line-number\">#{line_number}</span>#{CGI.escapeHTML(line_text.chomp)}</span>\n"
|
2239
|
+
end
|
2240
|
+
|
2241
|
+
html_content << " </code></pre>\n"
|
2242
|
+
html_content << " </div>\n" # end .code-container
|
2243
|
+
html_content << " </div>\n" # end .column
|
2244
|
+
end
|
2245
|
+
|
2246
|
+
generate_column_html.call("Base (#{base_branch_name})", base_content_full, base_highlight_lines, "base")
|
2247
|
+
generate_column_html.call("Incoming (#{incoming_branch_name})", incoming_content_full, incoming_highlight_lines, "incoming")
|
2248
|
+
generate_column_html.call("Current Resolution", current_resolution_content_full, resolution_highlight_lines, "resolution")
|
2249
|
+
|
2250
|
+
html_content << " </div>\n" # end .columns-container
|
2251
|
+
html_content << " <script>hljs.highlightAll();</script>\n"
|
2252
|
+
html_content << "</body>\n</html>"
|
2253
|
+
|
2254
|
+
# Save to file with unique but persistent naming
|
2255
|
+
log_dir = '.n2b_merge_log'
|
2256
|
+
FileUtils.mkdir_p(log_dir)
|
2257
|
+
|
2258
|
+
# Create a more stable filename based on file path and conflict location
|
2259
|
+
file_basename = File.basename(file_path, '.*')
|
2260
|
+
conflict_id = "#{block_details.start_line}_#{block_details.end_line}"
|
2261
|
+
preview_filename = "conflict_#{file_basename}_lines_#{conflict_id}.html"
|
2262
|
+
full_preview_path = File.join(log_dir, preview_filename)
|
2263
|
+
|
2264
|
+
File.write(full_preview_path, html_content.string)
|
2265
|
+
|
2266
|
+
return File.absolute_path(full_preview_path)
|
2267
|
+
end
|
2268
|
+
|
2269
|
+
def open_html_in_browser(html_file_path)
|
2270
|
+
absolute_path = File.absolute_path(html_file_path)
|
2271
|
+
# Ensure correct file URL format for different OSes, especially Windows
|
2272
|
+
file_url = if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
|
2273
|
+
"file:///#{absolute_path.gsub("\\", "/")}" # Windows needs forward slashes
|
2274
|
+
else
|
2275
|
+
"file://#{absolute_path}"
|
2276
|
+
end
|
2277
|
+
|
2278
|
+
command = nil
|
2279
|
+
os = RbConfig::CONFIG['host_os']
|
2280
|
+
|
2281
|
+
case os
|
2282
|
+
when /darwin|mac os/
|
2283
|
+
command = "open #{Shellwords.escape(file_url)}"
|
2284
|
+
when /linux/
|
2285
|
+
# Check for WSL environment, as xdg-open might not work as expected directly
|
2286
|
+
# or might open browser inside WSL, not on Windows host.
|
2287
|
+
# Powershell.exe can be used to open it on Windows host from WSL.
|
2288
|
+
if ENV['WSL_DISTRO_NAME'] || (ENV['IS_WSL'] == 'true') || File.exist?('/proc/sys/fs/binfmt_misc/WSLInterop')
|
2289
|
+
# Using powershell.exe to open the URL on the Windows host
|
2290
|
+
# Ensure the file_url is accessible from Windows (e.g. via /mnt/c/...)
|
2291
|
+
# This assumes the path is already a Windows-accessible path if running in WSL context
|
2292
|
+
# or that the user has set up their environment for this.
|
2293
|
+
# For file URLs, it's often better to translate to Windows path format.
|
2294
|
+
windows_path = absolute_path.gsub(%r{^/mnt/([a-z])}, '\1:') # Basic /mnt/c -> C:
|
2295
|
+
command = "powershell.exe -c \"Start-Process '#{windows_path}'\""
|
2296
|
+
puts "#{COLOR_YELLOW}Detected WSL, attempting to open in Windows browser: #{command}#{COLOR_RESET}"
|
2297
|
+
else
|
2298
|
+
command = "xdg-open #{Shellwords.escape(file_url)}"
|
2299
|
+
end
|
2300
|
+
when /mswin|mingw|cygwin/ # Windows
|
2301
|
+
# `start` command with an empty title "" for paths with spaces
|
2302
|
+
command = "start \"\" \"#{file_url.gsub("file:///", "")}\"" # `start` takes path directly
|
2303
|
+
else
|
2304
|
+
puts "#{COLOR_YELLOW}Unsupported OS: #{os}. Cannot open browser automatically.#{COLOR_RESET}"
|
2305
|
+
return false
|
2306
|
+
end
|
2307
|
+
|
2308
|
+
if command
|
2309
|
+
puts "#{COLOR_BLUE}Attempting to open preview in browser: #{command}#{COLOR_RESET}"
|
2310
|
+
begin
|
2311
|
+
success = system(command)
|
2312
|
+
unless success
|
2313
|
+
# system() returns false if command executes with non-zero status, nil if command fails to execute
|
2314
|
+
puts "#{COLOR_RED}Failed to execute command to open browser. Exit status: #{$?.exitstatus if $?}.#{COLOR_RESET}"
|
2315
|
+
raise "Command execution failed" # Will be caught by rescue
|
2316
|
+
end
|
2317
|
+
puts "#{COLOR_GREEN}Preview should now be open in your browser.#{COLOR_RESET}"
|
2318
|
+
return true
|
2319
|
+
rescue => e
|
2320
|
+
puts "#{COLOR_RED}Failed to automatically open the HTML preview: #{e.message}#{COLOR_RESET}"
|
2321
|
+
end
|
2322
|
+
end
|
2323
|
+
|
2324
|
+
puts "#{COLOR_YELLOW}Please open it manually: #{file_url}#{COLOR_RESET}"
|
2325
|
+
false
|
2326
|
+
end
|
2327
|
+
|
693
2328
|
end
|
694
2329
|
end
|