strata-cli 0.1.0.beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.standard.yml +3 -0
  3. data/CHANGELOG.md +5 -0
  4. data/CLAUDE.md +65 -0
  5. data/LICENSE +21 -0
  6. data/README.md +465 -0
  7. data/Rakefile +10 -0
  8. data/exe/strata +6 -0
  9. data/lib/strata/cli/ai/client.rb +63 -0
  10. data/lib/strata/cli/ai/configuration.rb +48 -0
  11. data/lib/strata/cli/ai/services/table_generator.rb +282 -0
  12. data/lib/strata/cli/api/client.rb +170 -0
  13. data/lib/strata/cli/api/connection_error_handler.rb +54 -0
  14. data/lib/strata/cli/configuration.rb +135 -0
  15. data/lib/strata/cli/credentials.rb +83 -0
  16. data/lib/strata/cli/descriptions/create/migration.txt +25 -0
  17. data/lib/strata/cli/descriptions/create/relation.txt +14 -0
  18. data/lib/strata/cli/descriptions/create/table.txt +23 -0
  19. data/lib/strata/cli/descriptions/datasource/add.txt +15 -0
  20. data/lib/strata/cli/descriptions/datasource/auth.txt +14 -0
  21. data/lib/strata/cli/descriptions/datasource/exec.txt +7 -0
  22. data/lib/strata/cli/descriptions/datasource/meta.txt +11 -0
  23. data/lib/strata/cli/descriptions/datasource/tables.txt +12 -0
  24. data/lib/strata/cli/descriptions/datasource/test.txt +8 -0
  25. data/lib/strata/cli/descriptions/deploy/deploy.txt +24 -0
  26. data/lib/strata/cli/descriptions/deploy/status.txt +9 -0
  27. data/lib/strata/cli/descriptions/init.txt +14 -0
  28. data/lib/strata/cli/generators/datasource.rb +83 -0
  29. data/lib/strata/cli/generators/group.rb +13 -0
  30. data/lib/strata/cli/generators/migration.rb +71 -0
  31. data/lib/strata/cli/generators/project.rb +190 -0
  32. data/lib/strata/cli/generators/relation.rb +64 -0
  33. data/lib/strata/cli/generators/table.rb +143 -0
  34. data/lib/strata/cli/generators/templates/adapters/athena.yml +53 -0
  35. data/lib/strata/cli/generators/templates/adapters/druid.yml +42 -0
  36. data/lib/strata/cli/generators/templates/adapters/duckdb.yml +36 -0
  37. data/lib/strata/cli/generators/templates/adapters/mysql.yml +45 -0
  38. data/lib/strata/cli/generators/templates/adapters/postgres.yml +48 -0
  39. data/lib/strata/cli/generators/templates/adapters/snowflake.yml +69 -0
  40. data/lib/strata/cli/generators/templates/adapters/sqlserver.yml +45 -0
  41. data/lib/strata/cli/generators/templates/adapters/trino.yml +56 -0
  42. data/lib/strata/cli/generators/templates/datasources.yml +4 -0
  43. data/lib/strata/cli/generators/templates/migration.rename.yml +15 -0
  44. data/lib/strata/cli/generators/templates/migration.swap.yml +13 -0
  45. data/lib/strata/cli/generators/templates/project.yml +36 -0
  46. data/lib/strata/cli/generators/templates/rel.domain.yml +43 -0
  47. data/lib/strata/cli/generators/templates/strata.yml +24 -0
  48. data/lib/strata/cli/generators/templates/table.table_name.yml +118 -0
  49. data/lib/strata/cli/generators/templates/test.yml +34 -0
  50. data/lib/strata/cli/generators/test.rb +48 -0
  51. data/lib/strata/cli/guard.rb +21 -0
  52. data/lib/strata/cli/helpers/color_helper.rb +103 -0
  53. data/lib/strata/cli/helpers/command_context.rb +41 -0
  54. data/lib/strata/cli/helpers/datasource_helper.rb +62 -0
  55. data/lib/strata/cli/helpers/description_helper.rb +18 -0
  56. data/lib/strata/cli/helpers/project_helper.rb +85 -0
  57. data/lib/strata/cli/helpers/prompts.rb +42 -0
  58. data/lib/strata/cli/helpers/table_filter.rb +48 -0
  59. data/lib/strata/cli/main.rb +71 -0
  60. data/lib/strata/cli/sub_commands/audit.rb +262 -0
  61. data/lib/strata/cli/sub_commands/create.rb +419 -0
  62. data/lib/strata/cli/sub_commands/datasource.rb +353 -0
  63. data/lib/strata/cli/sub_commands/deploy.rb +433 -0
  64. data/lib/strata/cli/sub_commands/project.rb +38 -0
  65. data/lib/strata/cli/sub_commands/table.rb +58 -0
  66. data/lib/strata/cli/terminal.rb +102 -0
  67. data/lib/strata/cli/ui/autocomplete.rb +93 -0
  68. data/lib/strata/cli/ui/field_editor.rb +215 -0
  69. data/lib/strata/cli/utils/archive.rb +137 -0
  70. data/lib/strata/cli/utils/deployment_monitor.rb +445 -0
  71. data/lib/strata/cli/utils/git.rb +253 -0
  72. data/lib/strata/cli/utils/import_manager.rb +190 -0
  73. data/lib/strata/cli/utils/test_reporter.rb +131 -0
  74. data/lib/strata/cli/utils/yaml_import_resolver.rb +91 -0
  75. data/lib/strata/cli/utils.rb +39 -0
  76. data/lib/strata/cli/version.rb +7 -0
  77. data/lib/strata/cli.rb +36 -0
  78. data/sig/strata/cli.rbs +6 -0
  79. metadata +306 -0
@@ -0,0 +1,445 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../helpers/color_helper"
4
+ require_relative "../terminal"
5
+ require "tty-spinner"
6
+ require "pastel"
7
+
8
+ module Strata
9
+ module CLI
10
+ module Utils
11
+ # Monitors deployment progress by polling the API and displaying stage updates
12
+ # with animated spinners that transition to checkmarks as stages complete.
13
+
14
+ class DeploymentMonitor
15
+ include Terminal
16
+
17
+ # Terminal statuses that indicate deployment is complete
18
+ TERMINAL_STATUSES = ["succeeded", "failed"].freeze
19
+ NOT_STARTED_STAGE = "not_started"
20
+ DEFAULT_POLL_INTERVAL = 0.5 # seconds
21
+ DEFAULT_TIMEOUT = 600 # seconds (10 minutes)
22
+ TEST_WAIT_TIMEOUT = 5 # seconds
23
+ STAGES = [
24
+ :not_started,
25
+ :preparing,
26
+ :pre_migrations,
27
+ :project_configuration,
28
+ :processing_datasources,
29
+ :processing_models,
30
+ :removing_deleted_models,
31
+ :processing_relationships,
32
+ :forming_universes,
33
+ :creating_blend_paths,
34
+ :validating_references,
35
+ :post_migrations,
36
+ :cleaning_up,
37
+ :finished
38
+ ].freeze
39
+ TESTING_STAGE = :running_tests
40
+
41
+ # Initialize a new DeploymentMonitor
42
+ #
43
+ # @param client [API::Client] The API client for fetching deployment status
44
+ # @param project_id [String] The project identifier
45
+ # @param branch_id [String] The branch identifier
46
+ # @param deployment_id [String, Integer] The deployment identifier
47
+ # @param has_tests [Boolean, nil] Whether tests exist for this deployment
48
+ # @raise [ArgumentError] if required parameters are nil or empty
49
+ def initialize(client, project_id, branch_id, deployment_id, has_tests: nil)
50
+ validate_initialization_params(client, project_id, branch_id, deployment_id)
51
+
52
+ @client = client
53
+ @project_id = project_id
54
+ @branch_id = branch_id
55
+ @deployment_id = deployment_id
56
+ @has_tests = has_tests
57
+ @spinners = {}
58
+ @completed_stages = Set.new
59
+ @seen_stages = []
60
+ @last_stage = nil
61
+ @tests_running = false
62
+ @deployment_completed = false
63
+ end
64
+
65
+ # Start monitoring deployment progress by polling the API
66
+ #
67
+ # @param poll_interval [Integer] Seconds between API polls (default: 2)
68
+ # @param timeout [Integer] Maximum seconds to monitor before timing out (default: 600)
69
+ # @param skip_initial_display [Boolean] Skip initial status messages (used when called after display_status)
70
+ # @return [Hash, nil] The final deployment hash, or nil if interrupted/errored
71
+ def start(poll_interval: DEFAULT_POLL_INTERVAL, timeout: DEFAULT_TIMEOUT, skip_initial_display: false)
72
+ unless skip_initial_display
73
+ display_initial_status
74
+ display_exit_instruction
75
+ end
76
+
77
+ start_time = Time.now
78
+
79
+ loop do
80
+ deployment = fetch_deployment_status
81
+
82
+ break if deployment.nil?
83
+
84
+ status = deployment_value(deployment, "status")
85
+ stage = deployment_value(deployment, "stage")
86
+
87
+ unless terminal_status?(status)
88
+ if @last_stage.nil? && stage
89
+ process_deployment_state(deployment)
90
+ elsif stage && stage != @last_stage
91
+ handle_stage_change(stage, status)
92
+ end
93
+ end
94
+
95
+ if terminal_status?(status) && !@deployment_completed
96
+ result = handle_deployment_completion(deployment, status, stage)
97
+ @deployment_completed = true
98
+ return result if result
99
+ end
100
+
101
+ if @deployment_completed && status == "succeeded" && stage == "finished"
102
+ latest_test_run = deployment_value(deployment, "latest_test_run")
103
+
104
+ if @has_tests == true && latest_test_run.nil?
105
+ start_stage_spinner(TESTING_STAGE) unless @tests_running
106
+ @tests_running = true
107
+ elsif latest_test_run
108
+ complete_stage(TESTING_STAGE) if @tests_running
109
+ @tests_running = false
110
+ test_results = deployment_value(latest_test_run, "test_results")
111
+ display_test_results(test_results) if test_results
112
+ return deployment
113
+ else
114
+ return deployment
115
+ end
116
+ end
117
+
118
+ if Time.now - start_time > timeout
119
+ complete_stage(TESTING_STAGE) if @tests_running
120
+ say "\n Monitoring timeout reached (#{timeout}s)", ColorHelper.warning
121
+ say " Deployment may still be in progress. Check server for status.\n", ColorHelper.info
122
+ return deployment
123
+ end
124
+
125
+ sleep(poll_interval)
126
+ end
127
+ rescue Interrupt
128
+ stop_all_spinners
129
+ say "\n\n Monitoring interrupted. Deployment continues in background.", ColorHelper.warning
130
+ say " Check server for deployment status.\n", ColorHelper.info
131
+ nil
132
+ rescue => e
133
+ stop_all_spinners
134
+ say "\n Error monitoring deployment: #{e.message}", ColorHelper.error
135
+ say " Check server for deployment status.\n", ColorHelper.info
136
+ nil
137
+ end
138
+
139
+ def display_status
140
+ deployment = fetch_deployment_status
141
+ return nil if deployment.nil?
142
+
143
+ display_initial_status
144
+ display_exit_instruction
145
+
146
+ process_deployment_state(deployment)
147
+
148
+ if terminal_status?(deployment_value(deployment, "status"))
149
+ display_final_status(deployment)
150
+ end
151
+
152
+ deployment
153
+ rescue Interrupt
154
+ stop_all_spinners
155
+ say "\n\n Exiting status view.\n", ColorHelper.info
156
+ nil
157
+ rescue => e
158
+ stop_all_spinners
159
+ say "\n Error fetching deployment status: #{e.message}", ColorHelper.error
160
+ nil
161
+ end
162
+
163
+ def process_deployment_state(deployment)
164
+ status = deployment_value(deployment, "status")
165
+ stage = deployment_value(deployment, "stage")
166
+
167
+ return unless stage && stage != NOT_STARTED_STAGE
168
+
169
+ process_stage(stage, status, track_seen: false)
170
+ end
171
+
172
+ private
173
+
174
+ def say(message, color_helper_result = nil)
175
+ return $stdout.puts(message) unless color_helper_result
176
+
177
+ colored_message = case color_helper_result
178
+ when Array
179
+ ColorHelper.pastel.decorate(message, *color_helper_result)
180
+ when Symbol
181
+ theme = ColorHelper::THEME[color_helper_result]
182
+ if theme
183
+ ColorHelper.pastel.decorate(message, *Array(theme))
184
+ else
185
+ begin
186
+ ColorHelper.pastel.send(color_helper_result, message)
187
+ rescue NoMethodError
188
+ message
189
+ end
190
+ end
191
+ else
192
+ message
193
+ end
194
+
195
+ $stdout.puts(colored_message)
196
+ end
197
+
198
+ def display_initial_status
199
+ say "\n Monitoring deployment progress...\n", ColorHelper.title
200
+ end
201
+
202
+ def display_exit_instruction
203
+ say " Press Ctrl+C to exit monitoring.\n", ColorHelper.dim
204
+ end
205
+
206
+ def fetch_deployment_status
207
+ @client.get_deployment(@project_id, @branch_id, @deployment_id)
208
+ rescue Strata::CommandError => e
209
+ say "\n Could not fetch deployment status: #{e.message}", ColorHelper.warning
210
+ nil
211
+ end
212
+
213
+ def handle_stage_change(stage, status = nil)
214
+ return if stage.nil? || stage == NOT_STARTED_STAGE
215
+
216
+ complete_previous_stage(stage.to_s)
217
+ process_stage(stage, status, track_seen: true)
218
+ end
219
+
220
+ def track_seen_stage(stage_string)
221
+ @seen_stages << stage_string unless @seen_stages.include?(stage_string)
222
+ end
223
+
224
+ def complete_previous_stage(current_stage_string)
225
+ return unless @last_stage && @last_stage != current_stage_string
226
+ return if @last_stage.to_s == NOT_STARTED_STAGE
227
+
228
+ complete_stage(@last_stage)
229
+ end
230
+
231
+ def complete_missed_stages(current_stage_string)
232
+ stage_symbol = current_stage_string.to_sym
233
+
234
+ if STAGES.include?(stage_symbol)
235
+ stages_array = STAGES.map(&:to_s)
236
+ complete_missed_stages_from_array(stages_array, current_stage_string)
237
+ else
238
+ # Fallback to dynamic tracking for unknown stages
239
+ complete_missed_stages_from_array(@seen_stages, current_stage_string)
240
+ end
241
+ end
242
+
243
+ def complete_missed_stages_from_array(stages_array, current_stage_string)
244
+ current_index = stages_array.index(current_stage_string)
245
+ return unless current_index
246
+
247
+ stages_array[0...current_index].each do |prev_stage|
248
+ next if prev_stage == NOT_STARTED_STAGE
249
+ next if @completed_stages.include?(prev_stage)
250
+
251
+ complete_stage(prev_stage)
252
+ end
253
+ end
254
+
255
+ def process_stage(stage, status = nil, track_seen: false)
256
+ stage_string = stage.to_s
257
+ return if stage_string == NOT_STARTED_STAGE || @completed_stages.include?(stage_string)
258
+
259
+ track_seen_stage(stage_string) if track_seen
260
+ complete_missed_stages(stage_string)
261
+
262
+ if status && terminal_status?(status)
263
+ # Already complete, just show it
264
+ complete_stage(stage_string)
265
+ else
266
+ # In progress, show spinner
267
+ start_stage_spinner(stage_string) unless @spinners[stage_string]
268
+ end
269
+
270
+ @last_stage = stage
271
+ end
272
+
273
+ def start_stage_spinner(stage_string)
274
+ return if @spinners[stage_string] # Already started
275
+
276
+ stage_name = format_stage_name(stage_string)
277
+ spinner = create_spinner(stage_name,
278
+ message_color: :cyan,
279
+ spinner_color: :cyan,
280
+ format: :dots,
281
+ clear: false)
282
+ spinner.auto_spin
283
+ @spinners[stage_string] = spinner
284
+ end
285
+
286
+ def complete_stage(stage)
287
+ stage_string = stage.to_s
288
+ return if stage_string == NOT_STARTED_STAGE || @completed_stages.include?(stage_string)
289
+
290
+ mark_spinner_as_success(stage_string)
291
+ @completed_stages.add(stage_string)
292
+ end
293
+
294
+ def mark_spinner_as_success(stage_string)
295
+ if @spinners[stage_string]
296
+ stop_spinner(stage_string, :success)
297
+ else
298
+ # Stage completed before spinner started - show completion directly
299
+ # This ensures consistent [✔] format for all stages
300
+ stage_name = format_stage_name(stage_string)
301
+ spinner = create_spinner(stage_name, message_color: :cyan, spinner_color: :cyan)
302
+ spinner.success("")
303
+ end
304
+ end
305
+
306
+ def terminal_status?(status)
307
+ TERMINAL_STATUSES.include?(status)
308
+ end
309
+
310
+ def display_final_status(deployment)
311
+ status = deployment_value(deployment, "status")
312
+ stage = deployment_value(deployment, "stage")
313
+ error_messages = deployment_value(deployment, "error_messages")
314
+
315
+ complete_remaining_stages(stage)
316
+ mark_final_stage_status(status, stage)
317
+ display_status_summary(status, stage, error_messages)
318
+
319
+ # Display test results for successful deployments, same format as monitor
320
+ display_test_results_if_available(deployment) if status == "succeeded" && stage == "finished"
321
+ end
322
+
323
+ def complete_remaining_stages(final_stage)
324
+ final_stage_string = final_stage&.to_s
325
+ @spinners.each_key do |stage_string|
326
+ next if stage_string == TESTING_STAGE # Handle test spinner separately
327
+ complete_stage(stage_string) if stage_string != final_stage_string
328
+ end
329
+ end
330
+
331
+ def mark_final_stage_status(status, stage)
332
+ return unless stage
333
+
334
+ stage_string = stage.to_s
335
+
336
+ case status
337
+ when "succeeded"
338
+ complete_stage(stage_string)
339
+ when "failed"
340
+ mark_stage_as_failed(stage_string)
341
+ end
342
+ end
343
+
344
+ def mark_stage_as_failed(stage_string)
345
+ stop_spinner(stage_string, :error)
346
+ @completed_stages.add(stage_string)
347
+ end
348
+
349
+ def stop_spinner(stage_string, status = :success)
350
+ return unless @spinners[stage_string]
351
+
352
+ # Pass empty string to avoid duplicate stage name (format already includes it)
353
+ @spinners[stage_string].send(status, "")
354
+ @spinners.delete(stage_string)
355
+ end
356
+
357
+ def display_status_summary(status, stage, error_messages)
358
+ case status
359
+ when "succeeded"
360
+ say "\n Deployment completed successfully!", :success
361
+ when "failed"
362
+ say "\n Deployment failed!", :error
363
+ say " Failed at stage: #{format_stage_name(stage)}", :info
364
+ display_error_details(error_messages) if error_messages && !error_messages.strip.empty?
365
+ end
366
+ end
367
+
368
+ def display_error_details(error_messages)
369
+ say "\n Error details:", :error
370
+ error_messages.split("\n").each do |line|
371
+ say " #{line}", :error
372
+ end
373
+ end
374
+
375
+ def display_test_results(test_results, skip_opening_divider: false)
376
+ require_relative "test_reporter"
377
+ reporter = Utils::TestReporter.new
378
+ reporter.display(test_results, skip_opening_divider: skip_opening_divider)
379
+ end
380
+
381
+ def format_stage_name(stage)
382
+ return "Not Started" unless stage
383
+
384
+ stage.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
385
+ end
386
+
387
+ def stop_all_spinners
388
+ @spinners.each do |_stage_string, spinner|
389
+ spinner.stop
390
+ end
391
+ @spinners.clear
392
+ @tests_running = false
393
+ end
394
+
395
+ def validate_initialization_params(client, project_id, branch_id, deployment_id)
396
+ raise ArgumentError, "client cannot be nil" if client.nil?
397
+ raise ArgumentError, "project_id cannot be nil or empty" if project_id.nil? || project_id.to_s.strip.empty?
398
+ raise ArgumentError, "branch_id cannot be nil or empty" if branch_id.nil? || branch_id.to_s.strip.empty?
399
+ raise ArgumentError, "deployment_id cannot be nil or empty" if deployment_id.nil? || deployment_id.to_s.strip.empty?
400
+ end
401
+
402
+ def deployment_value(deployment, key)
403
+ return nil unless deployment.is_a?(Hash)
404
+ deployment[key] || deployment[key.to_sym]
405
+ end
406
+
407
+ def handle_deployment_completion(deployment, status, stage)
408
+ if status == "succeeded" && stage == "finished"
409
+ complete_remaining_stages(stage)
410
+ complete_stage(stage)
411
+
412
+ error_messages = deployment_value(deployment, "error_messages")
413
+ display_status_summary(status, stage, error_messages)
414
+
415
+ latest_test_run = deployment_value(deployment, "latest_test_run")
416
+
417
+ # If tests are expected but not yet available, return nil to continue monitoring
418
+ return nil if latest_test_run.nil? && @has_tests == true
419
+
420
+ display_test_results_if_available(deployment)
421
+ return deployment
422
+ else
423
+ display_final_status(deployment)
424
+ return deployment
425
+ end
426
+ nil
427
+ end
428
+
429
+ def display_test_results_if_available(deployment)
430
+ latest_test_run = deployment_value(deployment, "latest_test_run")
431
+ return unless latest_test_run
432
+
433
+ test_results = deployment_value(latest_test_run, "test_results")
434
+ if test_results
435
+ say "\n" + "=" * 60, :border
436
+ complete_stage(TESTING_STAGE)
437
+ display_test_results(test_results, skip_opening_divider: true)
438
+ else
439
+ complete_stage(TESTING_STAGE)
440
+ end
441
+ end
442
+ end
443
+ end
444
+ end
445
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Strata
6
+ module CLI
7
+ module Utils
8
+ module Git
9
+ module_function
10
+
11
+ def validate_commit_hash(commit_hash)
12
+ return false unless commit_hash.is_a?(String)
13
+ return false unless commit_hash.match?(/\A[a-f0-9]{7,40}\z/i)
14
+ true
15
+ end
16
+
17
+ def current_branch
18
+ return "main" unless git_repo?
19
+
20
+ run_git_command("rev-parse", "--abbrev-ref", "HEAD")
21
+ end
22
+
23
+ def commit_info
24
+ sha = run_git_command("rev-parse", "HEAD")
25
+ message = run_git_command("log", "-1", "--pretty=%B")
26
+
27
+ {
28
+ sha: sha,
29
+ message: message || "Manual deployment"
30
+ }
31
+ end
32
+
33
+ def committer_info
34
+ name = run_git_command("config", "user.name")
35
+ email = run_git_command("config", "user.email")
36
+
37
+ {
38
+ name: name || "Unknown",
39
+ email: email || "unknown@example.com"
40
+ }
41
+ end
42
+
43
+ def file_modifications
44
+ diff_output = run_git_command("diff", "--name-status", "HEAD~1")
45
+ return [] unless diff_output
46
+
47
+ modifications = parse_diff_output(diff_output)
48
+ filter_yml_files(modifications)
49
+ end
50
+
51
+ def changed_files_since(commit_hash)
52
+ return [] unless git_repo?
53
+ return [] unless commit_hash
54
+
55
+ unless validate_commit_hash(commit_hash)
56
+ raise Strata::CommandError, "Invalid commit hash format: #{commit_hash.inspect}"
57
+ end
58
+
59
+ # Check if commit exists in repo
60
+ _, _, status = Open3.capture3("git", "rev-parse", "--verify", "#{commit_hash}^{commit}")
61
+ return [] unless status.success?
62
+
63
+ # Get all changed files since that commit (including added, modified, deleted, renamed)
64
+ diff_output = run_git_command("diff", "--name-status", commit_hash, "HEAD")
65
+ return [] unless diff_output
66
+
67
+ modifications = parse_diff_output(diff_output)
68
+ filter_yml_files(modifications)
69
+ end
70
+
71
+ def changed_file_paths_since(commit_hash)
72
+ return [] unless git_repo?
73
+ return [] unless commit_hash
74
+
75
+ unless validate_commit_hash(commit_hash)
76
+ raise Strata::CommandError, "Invalid commit hash format: #{commit_hash.inspect}"
77
+ end
78
+
79
+ # Check if commit exists in repo
80
+ _, _, status = Open3.capture3("git", "rev-parse", "--verify", "#{commit_hash}^{commit}")
81
+ return [] unless status.success?
82
+
83
+ # Get file paths that changed (for added and modified files only, exclude deleted)
84
+ # --diff-filter=ACMR: Added, Copied, Modified, Renamed (excludes Deleted)
85
+ diff_output = run_git_command("diff", "--name-status", "--diff-filter=ACMR", commit_hash, "HEAD")
86
+ return [] unless diff_output
87
+
88
+ paths = []
89
+ diff_output.lines.each do |line|
90
+ line = line.strip
91
+ next if line.empty?
92
+
93
+ parts = line.split(/\s+/)
94
+ next if parts.length < 2
95
+
96
+ status = parts[0]
97
+ # For renames (R), parts[1] is old path, parts[2] is new path
98
+ # For others, parts[1] is the path
99
+ if status.start_with?("R")
100
+ # Rename: use the new path
101
+ paths << parts[2] if parts.length >= 3 && parts[2]
102
+ elsif parts[1]
103
+ # Added, Copied, Modified: use the path
104
+ paths << parts[1]
105
+ end
106
+ end
107
+
108
+ # Filter to only yml files and return unique paths
109
+ paths.select { |path| path&.end_with?(".yml", ".yaml") }.uniq
110
+ end
111
+
112
+ def git_repo?
113
+ File.directory?(".git")
114
+ end
115
+
116
+ def git_remote_url(remote_name = "origin")
117
+ return nil unless git_repo?
118
+
119
+ url = run_git_command("remote", "get-url", remote_name)
120
+ return nil if url.nil? || url.strip.empty?
121
+
122
+ url.strip
123
+ rescue
124
+ nil
125
+ end
126
+
127
+ def run_git_command(*args)
128
+ stdout, _, _ = Open3.capture3("git", *args)
129
+ stdout&.strip
130
+ end
131
+
132
+ def parse_diff_output(output)
133
+ output.lines.map do |line|
134
+ parse_diff_status(line.strip)
135
+ end.compact
136
+ end
137
+
138
+ def parse_diff_status(line)
139
+ return nil if line.empty?
140
+
141
+ parts = line.split(/\s+/, 2)
142
+ return nil if parts.length < 2
143
+
144
+ status = parts[0]
145
+ path = parts[1]
146
+
147
+ format_file_modification(status, path)
148
+ end
149
+
150
+ def format_file_modification(status, path)
151
+ case status
152
+ when /^R(\d+)$/
153
+ similarity = Regexp.last_match(1)
154
+ old_path, new_path = path.split(/\s+/, 2)
155
+ ["R#{similarity}", old_path, new_path]
156
+ when "A"
157
+ ["A", path]
158
+ when "M"
159
+ ["M", path]
160
+ when "D"
161
+ ["D", path]
162
+ end
163
+ end
164
+
165
+ def filter_yml_files(modifications)
166
+ modifications.select do |mod|
167
+ path = mod.is_a?(Array) ? (mod[1] || "") : ""
168
+ path.end_with?(".yml", ".yaml")
169
+ end
170
+ end
171
+
172
+ def uncommitted_changes?
173
+ return false unless git_repo?
174
+
175
+ stdout, _, _ = Open3.capture3("git", "status", "--porcelain")
176
+ !stdout.strip.empty?
177
+ end
178
+
179
+ def fetch_branch(branch_name)
180
+ return unless git_repo?
181
+
182
+ run_git_command("fetch", "origin", branch_name)
183
+ end
184
+
185
+ def check_commit_status(branch_name)
186
+ return {status: :ok, message: "Not a git repository"} unless git_repo?
187
+
188
+ remote_branch = "origin/#{branch_name}"
189
+
190
+ # Check if remote branch exists
191
+ remote_sha, _, remote_status = Open3.capture3("git", "rev-parse", remote_branch)
192
+ unless remote_status.success?
193
+ return {status: :ok, message: "Remote branch not found, proceeding with local commit"}
194
+ end
195
+
196
+ remote_sha = remote_sha.strip
197
+ local_sha = run_git_command("rev-parse", "HEAD")
198
+
199
+ # If SHAs are the same, branches are in sync
200
+ return {status: :same, message: "Local branch is in sync with remote"} if local_sha == remote_sha
201
+
202
+ # Check if local is ahead, behind, or diverged
203
+ # rev-list --left-right shows: > = commits in local not in remote (ahead), < = commits in remote not in local (behind)
204
+ diff_output, _, _ = Open3.capture3("git", "rev-list", "--left-right", "#{remote_sha}...#{local_sha}")
205
+
206
+ ahead_count = diff_output.lines.count { |line| line.start_with?(">") }
207
+ behind_count = diff_output.lines.count { |line| line.start_with?("<") }
208
+
209
+ if ahead_count > 0 && behind_count == 0
210
+ {status: :ahead, message: "Local branch is #{ahead_count} commit(s) ahead of remote"}
211
+ elsif ahead_count == 0 && behind_count > 0
212
+ {status: :behind, message: "Local branch is #{behind_count} commit(s) behind remote"}
213
+ else
214
+ {status: :diverged, message: "Local branch has diverged from remote (#{ahead_count} ahead, #{behind_count} behind)"}
215
+ end
216
+ end
217
+
218
+ def commit_file(file_path, commit_message, project_path = Dir.pwd)
219
+ return false unless git_repo?
220
+
221
+ # Change to project directory for git operations
222
+ Dir.chdir(project_path) do
223
+ # Check if file exists
224
+ return false unless File.exist?(file_path)
225
+
226
+ # Check if file has changes (modified, added, or untracked)
227
+ stdout, _, status = Open3.capture3("git", "status", "--porcelain", file_path)
228
+ return false unless status.success?
229
+
230
+ # If no changes, nothing to commit
231
+ return false if stdout.strip.empty?
232
+
233
+ # Stage the file (handles both modified and untracked files)
234
+ _, _, add_status = Open3.capture3("git", "add", file_path)
235
+ return false unless add_status.success?
236
+
237
+ # Check if there are actually staged changes to commit
238
+ # git diff --cached --quiet returns 0 if no changes, 1 if changes exist
239
+ _, _, diff_status = Open3.capture3("git", "diff", "--cached", "--quiet", file_path)
240
+ # If success? is true (exitstatus == 0), there are no changes staged
241
+ return false if diff_status.success?
242
+
243
+ # Commit the file
244
+ _, _, commit_status = Open3.capture3("git", "commit", "-m", commit_message)
245
+ commit_status.success?
246
+ end
247
+ rescue
248
+ false
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end