sxn 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +7 -0
  2. data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
  4. data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
  5. data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
  6. data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
  7. data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
  8. data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
  9. data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
  10. data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
  11. data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
  12. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
  13. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  15. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  16. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
  17. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  18. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
  19. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  20. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  21. data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
  22. data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
  23. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  25. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  26. data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
  27. data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
  28. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  29. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  30. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  32. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  33. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
  35. data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
  37. data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
  38. data/.rspec +4 -0
  39. data/.rubocop.yml +121 -0
  40. data/.simplecov +51 -0
  41. data/CHANGELOG.md +49 -0
  42. data/Gemfile +24 -0
  43. data/Gemfile.lock +329 -0
  44. data/LICENSE.txt +21 -0
  45. data/README.md +225 -0
  46. data/Rakefile +54 -0
  47. data/Steepfile +50 -0
  48. data/bin/sxn +6 -0
  49. data/lib/sxn/CLI.rb +275 -0
  50. data/lib/sxn/commands/init.rb +137 -0
  51. data/lib/sxn/commands/projects.rb +350 -0
  52. data/lib/sxn/commands/rules.rb +435 -0
  53. data/lib/sxn/commands/sessions.rb +300 -0
  54. data/lib/sxn/commands/worktrees.rb +416 -0
  55. data/lib/sxn/commands.rb +13 -0
  56. data/lib/sxn/config/config_cache.rb +295 -0
  57. data/lib/sxn/config/config_discovery.rb +242 -0
  58. data/lib/sxn/config/config_validator.rb +562 -0
  59. data/lib/sxn/config.rb +259 -0
  60. data/lib/sxn/core/config_manager.rb +290 -0
  61. data/lib/sxn/core/project_manager.rb +307 -0
  62. data/lib/sxn/core/rules_manager.rb +306 -0
  63. data/lib/sxn/core/session_manager.rb +336 -0
  64. data/lib/sxn/core/worktree_manager.rb +281 -0
  65. data/lib/sxn/core.rb +13 -0
  66. data/lib/sxn/database/errors.rb +29 -0
  67. data/lib/sxn/database/session_database.rb +691 -0
  68. data/lib/sxn/database.rb +24 -0
  69. data/lib/sxn/errors.rb +76 -0
  70. data/lib/sxn/rules/base_rule.rb +367 -0
  71. data/lib/sxn/rules/copy_files_rule.rb +346 -0
  72. data/lib/sxn/rules/errors.rb +28 -0
  73. data/lib/sxn/rules/project_detector.rb +871 -0
  74. data/lib/sxn/rules/rules_engine.rb +485 -0
  75. data/lib/sxn/rules/setup_commands_rule.rb +307 -0
  76. data/lib/sxn/rules/template_rule.rb +262 -0
  77. data/lib/sxn/rules.rb +148 -0
  78. data/lib/sxn/runtime_validations.rb +96 -0
  79. data/lib/sxn/security/secure_command_executor.rb +364 -0
  80. data/lib/sxn/security/secure_file_copier.rb +478 -0
  81. data/lib/sxn/security/secure_path_validator.rb +258 -0
  82. data/lib/sxn/security.rb +15 -0
  83. data/lib/sxn/templates/common/gitignore.liquid +99 -0
  84. data/lib/sxn/templates/common/session-info.md.liquid +58 -0
  85. data/lib/sxn/templates/errors.rb +36 -0
  86. data/lib/sxn/templates/javascript/README.md.liquid +59 -0
  87. data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
  88. data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
  89. data/lib/sxn/templates/rails/database.yml.liquid +31 -0
  90. data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
  91. data/lib/sxn/templates/template_engine.rb +346 -0
  92. data/lib/sxn/templates/template_processor.rb +279 -0
  93. data/lib/sxn/templates/template_security.rb +410 -0
  94. data/lib/sxn/templates/template_variables.rb +713 -0
  95. data/lib/sxn/templates.rb +28 -0
  96. data/lib/sxn/ui/output.rb +103 -0
  97. data/lib/sxn/ui/progress_bar.rb +91 -0
  98. data/lib/sxn/ui/prompt.rb +116 -0
  99. data/lib/sxn/ui/table.rb +183 -0
  100. data/lib/sxn/ui.rb +12 -0
  101. data/lib/sxn/version.rb +5 -0
  102. data/lib/sxn.rb +63 -0
  103. data/rbs_collection.lock.yaml +180 -0
  104. data/rbs_collection.yaml +39 -0
  105. data/scripts/test.sh +31 -0
  106. data/sig/external/liquid.rbs +116 -0
  107. data/sig/external/thor.rbs +99 -0
  108. data/sig/external/tty.rbs +71 -0
  109. data/sig/sxn/cli.rbs +46 -0
  110. data/sig/sxn/commands/init.rbs +38 -0
  111. data/sig/sxn/commands/projects.rbs +72 -0
  112. data/sig/sxn/commands/rules.rbs +95 -0
  113. data/sig/sxn/commands/sessions.rbs +62 -0
  114. data/sig/sxn/commands/worktrees.rbs +82 -0
  115. data/sig/sxn/commands.rbs +6 -0
  116. data/sig/sxn/config/config_cache.rbs +67 -0
  117. data/sig/sxn/config/config_discovery.rbs +64 -0
  118. data/sig/sxn/config/config_validator.rbs +64 -0
  119. data/sig/sxn/config.rbs +74 -0
  120. data/sig/sxn/core/config_manager.rbs +67 -0
  121. data/sig/sxn/core/project_manager.rbs +52 -0
  122. data/sig/sxn/core/rules_manager.rbs +54 -0
  123. data/sig/sxn/core/session_manager.rbs +59 -0
  124. data/sig/sxn/core/worktree_manager.rbs +50 -0
  125. data/sig/sxn/core.rbs +87 -0
  126. data/sig/sxn/database/errors.rbs +37 -0
  127. data/sig/sxn/database/session_database.rbs +151 -0
  128. data/sig/sxn/database.rbs +83 -0
  129. data/sig/sxn/errors.rbs +89 -0
  130. data/sig/sxn/rules/base_rule.rbs +137 -0
  131. data/sig/sxn/rules/copy_files_rule.rbs +65 -0
  132. data/sig/sxn/rules/errors.rbs +33 -0
  133. data/sig/sxn/rules/project_detector.rbs +115 -0
  134. data/sig/sxn/rules/rules_engine.rbs +118 -0
  135. data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
  136. data/sig/sxn/rules/template_rule.rbs +44 -0
  137. data/sig/sxn/rules.rbs +287 -0
  138. data/sig/sxn/runtime_validations.rbs +16 -0
  139. data/sig/sxn/security/secure_command_executor.rbs +63 -0
  140. data/sig/sxn/security/secure_file_copier.rbs +79 -0
  141. data/sig/sxn/security/secure_path_validator.rbs +30 -0
  142. data/sig/sxn/security.rbs +128 -0
  143. data/sig/sxn/templates/errors.rbs +43 -0
  144. data/sig/sxn/templates/template_engine.rbs +50 -0
  145. data/sig/sxn/templates/template_processor.rbs +44 -0
  146. data/sig/sxn/templates/template_security.rbs +62 -0
  147. data/sig/sxn/templates/template_variables.rbs +103 -0
  148. data/sig/sxn/templates.rbs +104 -0
  149. data/sig/sxn/ui/output.rbs +50 -0
  150. data/sig/sxn/ui/progress_bar.rbs +39 -0
  151. data/sig/sxn/ui/prompt.rbs +38 -0
  152. data/sig/sxn/ui/table.rbs +43 -0
  153. data/sig/sxn/ui.rbs +63 -0
  154. data/sig/sxn/version.rbs +5 -0
  155. data/sig/sxn.rbs +29 -0
  156. metadata +635 -0
@@ -0,0 +1,713 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "time"
5
+ require "json"
6
+ require "yaml"
7
+ require "open3"
8
+ require "timeout"
9
+
10
+ module Sxn
11
+ module Templates
12
+ # TemplateVariables collects and prepares variables for template processing.
13
+ # It gathers context from sessions, git repositories, projects, and environment.
14
+ #
15
+ # Variable Categories:
16
+ # - session: Current session information (name, path, created_at, etc.)
17
+ # - git: Git repository information (branch, author, last_commit, etc.)
18
+ # - project: Project details (name, type, path, etc.)
19
+ # - environment: Runtime environment (ruby version, rails version, etc.)
20
+ # - user: User preferences and git configuration
21
+ #
22
+ # Example:
23
+ # collector = TemplateVariables.new(session, project)
24
+ # variables = collector.collect
25
+ # # => {
26
+ # # session: { name: "ATL-1234", path: "/path/to/session" },
27
+ # # git: { branch: "feature/cart", author: "John Doe" },
28
+ # # project: { name: "atlas-core", type: "rails" }
29
+ # # }
30
+ class TemplateVariables
31
+ # Git command timeout in seconds
32
+ GIT_TIMEOUT = 5
33
+
34
+ def initialize(session = nil, project = nil, config = nil)
35
+ @session = session
36
+ @project = project
37
+ @config = config
38
+ @cached_variables = {}
39
+ end
40
+
41
+ # Collect all template variables
42
+ #
43
+ # @return [Hash] Complete set of variables for template processing
44
+ def collect
45
+ return @cached_variables unless @cached_variables.empty?
46
+
47
+ # steep:ignore:start - Template variable collection uses metaprogramming
48
+ # The template system uses dynamic method calls and variable resolution that
49
+ # cannot be statically typed. Runtime validation ensures type safety.
50
+ @cached_variables = {
51
+ session: _collect_session_variables,
52
+ git: _collect_git_variables,
53
+ project: _collect_project_variables,
54
+ environment: _collect_environment_variables,
55
+ user: _collect_user_variables,
56
+ timestamp: _collect_timestamp_variables
57
+ }.compact
58
+
59
+ # Runtime validation of collected variables
60
+ validate_collected_variables(@cached_variables)
61
+
62
+ @cached_variables
63
+ end
64
+
65
+ # Alias for collect method to maintain backwards compatibility with tests
66
+ alias build_variables collect
67
+
68
+ # Refresh cached variables (useful for long-running processes)
69
+ def refresh!
70
+ @cached_variables = {}
71
+ collect
72
+ end
73
+
74
+ # Get variables for a specific category
75
+ #
76
+ # @param category [Symbol] The variable category (:session, :git, :project, etc.)
77
+ # @return [Hash] Variables for the specified category
78
+ def get_category(category)
79
+ collect[category] || {}
80
+ end
81
+
82
+ # Add custom variables that will be merged with collected variables
83
+ #
84
+ # @param custom_vars [Hash] Custom variables to add
85
+ def add_custom_variables(custom_vars)
86
+ return unless custom_vars.is_a?(Hash)
87
+
88
+ # Merge custom variables, with custom taking precedence
89
+ @cached_variables = collect.deep_merge(custom_vars)
90
+ end
91
+
92
+ # Public aliases for collection methods (expected by tests)
93
+ def collect_session_variables
94
+ get_category(:session)
95
+ end
96
+
97
+ def collect_project_variables
98
+ get_category(:project)
99
+ end
100
+
101
+ def collect_git_variables
102
+ get_category(:git)
103
+ end
104
+
105
+ def collect_environment_variables
106
+ get_category(:environment)
107
+ end
108
+
109
+ def collect_user_variables
110
+ get_category(:user)
111
+ end
112
+
113
+ def collect_timestamp_variables
114
+ get_category(:timestamp)
115
+ end
116
+
117
+ # Detect Ruby version
118
+ def detect_ruby_version
119
+ return "Unknown Ruby version" if RUBY_VERSION.nil?
120
+
121
+ RUBY_VERSION
122
+ rescue StandardError => e
123
+ "Unknown Ruby version: #{e.message}"
124
+ end
125
+
126
+ # Detect Rails version if available
127
+ def detect_rails_version
128
+ return nil unless rails_available?
129
+
130
+ result = collect_rails_version
131
+ result.is_a?(Hash) && result[:version] ? result[:version] : nil
132
+ rescue StandardError
133
+ nil
134
+ end
135
+
136
+ # Detect Node.js version if available
137
+ def detect_node_version
138
+ return nil unless node_available?
139
+
140
+ result = collect_node_version
141
+ result.is_a?(Hash) && result[:version] ? result[:version] : nil
142
+ rescue StandardError
143
+ nil
144
+ end
145
+
146
+ private
147
+
148
+ # Validate collected template variables
149
+ # Ensures all variable categories contain expected data types
150
+ def validate_collected_variables(variables)
151
+ return variables unless variables.is_a?(Hash)
152
+
153
+ variables.each do |category, data|
154
+ next unless data.is_a?(Hash)
155
+
156
+ # Validate each variable can be safely used in templates
157
+ data.each do |key, value|
158
+ next if value.nil?
159
+
160
+ # Ensure values are template-safe (can be converted to strings)
161
+ Sxn.logger&.warn("Template variable #{category}.#{key} cannot be safely stringified: #{value.class}") unless value.respond_to?(:to_s)
162
+
163
+ # Check for potentially problematic objects
164
+ if value.is_a?(Proc) || value.is_a?(Method)
165
+ Sxn.logger&.warn("Template variable #{category}.#{key} contains executable code - security risk")
166
+ end
167
+ end
168
+ end
169
+
170
+ variables
171
+ rescue StandardError => e
172
+ Sxn.logger&.error("Template variable validation failed: #{e.message}")
173
+ variables # Return variables anyway, but log the issue
174
+ end
175
+
176
+ # Collect session-related variables
177
+ def _collect_session_variables
178
+ return {} unless @session
179
+
180
+ session_vars = {
181
+ name: @session.name,
182
+ path: @session.path.to_s,
183
+ created_at: format_timestamp(@session.created_at),
184
+ updated_at: format_timestamp(@session.updated_at),
185
+ status: @session.status
186
+ }
187
+
188
+ # Add optional session fields if present
189
+ session_vars[:linear_task] = @session.linear_task if @session.respond_to?(:linear_task) && @session.linear_task
190
+ session_vars[:description] = @session.description if @session.respond_to?(:description) && @session.description
191
+ session_vars[:projects] = @session.projects if @session.respond_to?(:projects) && @session.projects
192
+ session_vars[:tags] = @session.tags if @session.respond_to?(:tags) && @session.tags
193
+
194
+ # Add worktree information if available
195
+ if @session.respond_to?(:worktrees) && @session.worktrees
196
+ session_vars[:worktrees] = @session.worktrees.map do |worktree|
197
+ {
198
+ name: worktree.name,
199
+ path: worktree.path.to_s,
200
+ branch: worktree.branch,
201
+ created_at: format_timestamp(worktree.created_at)
202
+ }
203
+ end
204
+ end
205
+
206
+ session_vars
207
+ rescue StandardError => e
208
+ { error: "Failed to collect session variables: #{e.message}" }
209
+ end
210
+
211
+ # Collect git repository variables
212
+ def _collect_git_variables
213
+ # Determine git directory - prefer project path, fall back to session path
214
+ git_dir = find_git_directory
215
+
216
+ # Return with available: false if no git directory found
217
+ return { available: false } unless git_dir
218
+
219
+ git_vars = {}
220
+
221
+ git_vars[:available] = true
222
+ # Collect git information with timeout protection
223
+ git_vars.merge!(collect_git_branch_info(git_dir))
224
+
225
+ # Collect author info and structure it properly for templates
226
+ author_info = collect_git_author_info(git_dir)
227
+ if author_info[:author_name] || author_info[:author_email]
228
+ git_vars[:author] = {
229
+ name: author_info[:author_name],
230
+ email: author_info[:author_email]
231
+ }
232
+ end
233
+
234
+ git_vars.merge!(collect_git_commit_info(git_dir))
235
+ git_vars.merge!(collect_git_remote_info(git_dir))
236
+ git_vars.merge!(collect_git_status_info(git_dir))
237
+
238
+ git_vars
239
+ rescue StandardError => e
240
+ { available: false, error: "Failed to collect git variables: #{e.message}" }
241
+ end
242
+
243
+ # Collect project-related variables
244
+ def _collect_project_variables
245
+ return {} unless @project
246
+
247
+ project_vars = {
248
+ name: @project.name,
249
+ path: @project.path.to_s,
250
+ type: detect_project_type(@project.path)
251
+ }
252
+
253
+ # Add language-specific information
254
+ case project_vars[:type]
255
+ when "rails"
256
+ project_vars.merge!(collect_rails_project_info)
257
+ when "javascript", "typescript", "nodejs"
258
+ project_vars.merge!(collect_js_project_info)
259
+ when "ruby"
260
+ project_vars.merge!(collect_ruby_project_info)
261
+ end
262
+
263
+ project_vars
264
+ rescue StandardError => e
265
+ { error: "Failed to collect project variables: #{e.message}" }
266
+ end
267
+
268
+ # Collect environment variables
269
+ def _collect_environment_variables
270
+ env_vars = {}
271
+
272
+ # Ruby environment
273
+ env_vars[:ruby] = {
274
+ version: RUBY_VERSION,
275
+ platform: RUBY_PLATFORM,
276
+ patchlevel: RUBY_PATCHLEVEL
277
+ }
278
+
279
+ # Rails information if in Rails project
280
+ env_vars[:rails] = collect_rails_version if rails_available?
281
+
282
+ # Node.js information if available
283
+ env_vars[:node] = collect_node_version if node_available?
284
+
285
+ # Database information
286
+ env_vars[:database] = collect_database_info
287
+
288
+ # Operating system
289
+ env_vars[:os] = {
290
+ name: RbConfig::CONFIG["host_os"],
291
+ arch: RbConfig::CONFIG["host_cpu"]
292
+ }
293
+
294
+ env_vars
295
+ rescue StandardError => e
296
+ { error: "Failed to collect environment variables: #{e.message}" }
297
+ end
298
+
299
+ # Collect user preferences and configuration
300
+ def _collect_user_variables
301
+ user_vars = {}
302
+
303
+ # Git user configuration
304
+ user_vars.merge!(collect_git_user_config)
305
+
306
+ # User preferences from sxn config
307
+ if @config
308
+ user_vars[:editor] = @config.default_editor if @config.respond_to?(:default_editor)
309
+ user_vars[:preferences] = @config.user_preferences if @config.respond_to?(:user_preferences)
310
+ end
311
+
312
+ # System user information
313
+ user_vars[:username] = ENV["USER"] || ENV.fetch("USERNAME", nil)
314
+ user_vars[:home] = Dir.home
315
+
316
+ user_vars.compact
317
+ rescue StandardError => e
318
+ { error: "Failed to collect user variables: #{e.message}" }
319
+ end
320
+
321
+ # Collect timestamp variables for template generation
322
+ def _collect_timestamp_variables
323
+ now = Time.now
324
+ {
325
+ now: format_timestamp(now),
326
+ today: now.strftime("%Y-%m-%d"),
327
+ year: now.year,
328
+ month: now.month,
329
+ day: now.day,
330
+ iso8601: now.iso8601,
331
+ epoch: now.to_i
332
+ }
333
+ end
334
+
335
+ # Format timestamp for template display
336
+ def format_timestamp(timestamp)
337
+ return nil unless timestamp
338
+
339
+ timestamp = Time.parse(timestamp.to_s) unless timestamp.is_a?(Time)
340
+ timestamp.strftime("%Y-%m-%d %H:%M:%S %Z")
341
+ rescue StandardError
342
+ timestamp.to_s
343
+ end
344
+
345
+ # Find the git directory for the current context
346
+ def find_git_directory
347
+ candidates = []
348
+
349
+ # Try project path first
350
+ candidates << @project.path if @project&.path
351
+
352
+ # Try session path
353
+ candidates << @session.path if @session&.path
354
+
355
+ # Try current directory
356
+ candidates << Pathname.pwd
357
+
358
+ candidates.find { |path| git_repository?(path) }
359
+ end
360
+
361
+ # Check if directory is a git repository
362
+ def git_repository?(path)
363
+ return false unless path
364
+
365
+ path_str = path.to_s
366
+
367
+ # First check for .git directory
368
+ return true if File.exist?(File.join(path_str, ".git"))
369
+
370
+ # Then check with git command - if it fails (returns nil), not a git repo
371
+ execute_git_command(path, "rev-parse", "--git-dir") do |output|
372
+ return true if output && !output.strip.empty?
373
+ end
374
+
375
+ false
376
+ end
377
+
378
+ # Collect git branch information
379
+ def collect_git_branch_info(git_dir)
380
+ branch_info = {}
381
+
382
+ # Current branch
383
+ execute_git_command(git_dir, "branch", "--show-current") do |output|
384
+ branch_info[:branch] = output.strip
385
+ end
386
+
387
+ # Remote tracking branch
388
+ execute_git_command(git_dir, "rev-parse", "--abbrev-ref", "@{upstream}") do |output|
389
+ branch_info[:upstream] = output.strip
390
+ end
391
+
392
+ # Check if working directory is clean
393
+ execute_git_command(git_dir, "status", "--porcelain") do |output|
394
+ branch_info[:clean] = output.strip.empty?
395
+ branch_info[:has_changes] = !output.strip.empty?
396
+ end
397
+
398
+ branch_info
399
+ end
400
+
401
+ # Collect git author information
402
+ def collect_git_author_info(git_dir)
403
+ author_info = {}
404
+
405
+ # Author name and email from config
406
+ execute_git_command(git_dir, "config", "user.name") do |output|
407
+ author_info[:author_name] = output.strip
408
+ end
409
+
410
+ execute_git_command(git_dir, "config", "user.email") do |output|
411
+ author_info[:author_email] = output.strip
412
+ end
413
+
414
+ author_info
415
+ end
416
+
417
+ # Collect git commit information
418
+ def collect_git_commit_info(git_dir)
419
+ commit_info = {}
420
+
421
+ # Last commit information
422
+ execute_git_command(git_dir, "log", "-1", "--format=%H|%s|%an|%ae|%ai") do |output|
423
+ parts = output.strip.split("|", 5)
424
+ if parts.length >= 4
425
+ commit_info[:last_commit] = {
426
+ sha: parts[0],
427
+ message: parts[1],
428
+ author_name: parts[2],
429
+ author_email: parts[3],
430
+ date: parts[4]
431
+ }
432
+ end
433
+ end
434
+
435
+ # Short SHA
436
+ execute_git_command(git_dir, "rev-parse", "--short", "HEAD") do |output|
437
+ commit_info[:short_sha] = output.strip
438
+ end
439
+
440
+ commit_info
441
+ end
442
+
443
+ # Collect git remote information
444
+ def collect_git_remote_info(git_dir)
445
+ remote_info = {}
446
+
447
+ # Default remote (usually origin)
448
+ execute_git_command(git_dir, "remote") do |output|
449
+ remotes = output.strip.split("\n")
450
+ remote_info[:remotes] = remotes
451
+ remote_info[:default_remote] = remotes.include?("origin") ? "origin" : remotes.first
452
+ end
453
+
454
+ # Remote URL
455
+ if remote_info[:default_remote]
456
+ execute_git_command(git_dir, "remote", "get-url", remote_info[:default_remote]) do |output|
457
+ remote_info[:remote_url] = output.strip
458
+ end
459
+ end
460
+
461
+ remote_info
462
+ end
463
+
464
+ # Collect git status information
465
+ def collect_git_status_info(git_dir)
466
+ status_data = {}
467
+
468
+ # Count of modified, added, deleted files
469
+ execute_git_command(git_dir, "status", "--porcelain") do |output|
470
+ lines = output.strip.split("\n").reject(&:empty?)
471
+ status_data[:modified_files] = lines.select { |line| line.start_with?(" M", "MM") }.length
472
+ status_data[:added_files] = lines.select { |line| line.start_with?("A ", "AM") }.length
473
+ status_data[:deleted_files] = lines.select { |line| line.start_with?(" D", "AD") }.length
474
+ status_data[:untracked_files] = lines.select { |line| line.start_with?("??") }.length
475
+ status_data[:total_changes] = lines.length
476
+
477
+ # Add human-readable status
478
+ status_data[:status] = if lines.any?
479
+ "Has uncommitted changes"
480
+ else
481
+ "Clean working directory"
482
+ end
483
+ end
484
+
485
+ # Return with 'status' as nested object for template compatibility
486
+ { status: status_data }
487
+ end
488
+
489
+ # Collect git user configuration
490
+ def collect_git_user_config
491
+ config = {}
492
+
493
+ execute_git_command(nil, "config", "--global", "user.name") do |output|
494
+ config[:git_name] = output.strip
495
+ end
496
+
497
+ execute_git_command(nil, "config", "--global", "user.email") do |output|
498
+ config[:git_email] = output.strip
499
+ end
500
+
501
+ config
502
+ end
503
+
504
+ # Execute git command with timeout and error handling
505
+ def execute_git_command(directory, *args)
506
+ cmd = ["git"] + args
507
+ options = {}
508
+ options[:chdir] = directory.to_s if directory
509
+
510
+ output = nil
511
+
512
+ begin
513
+ Open3.popen3(*cmd, **options) do |stdin, stdout, _stderr, wait_thr|
514
+ stdin.close
515
+
516
+ # Wait for command with timeout
517
+ if wait_thr.join(GIT_TIMEOUT)
518
+ if wait_thr.value.success?
519
+ output = stdout.read
520
+ yield output if block_given?
521
+ end
522
+ else
523
+ begin
524
+ Process.kill("TERM", wait_thr.pid)
525
+ rescue StandardError
526
+ nil
527
+ end
528
+ return nil
529
+ end
530
+ end
531
+ rescue StandardError
532
+ # Silently ignore git command failures - templates should still work
533
+ # even if git information is unavailable
534
+ return nil
535
+ end
536
+
537
+ output
538
+ end
539
+
540
+ # Detect project type based on file patterns
541
+ def detect_project_type(project_path)
542
+ return "unknown" unless project_path
543
+
544
+ path = Pathname.new(project_path)
545
+
546
+ # Rails detection
547
+ return "rails" if (path / "Gemfile").exist? &&
548
+ (path / "config" / "application.rb").exist?
549
+
550
+ # Ruby gem detection
551
+ return "ruby" if (path / "Gemfile").exist? || Dir.glob((path / "*.gemspec").to_s).any?
552
+
553
+ # Node.js/JavaScript detection
554
+ if (path / "package.json").exist?
555
+ package_json = JSON.parse((path / "package.json").read)
556
+ return "nextjs" if package_json.dig("dependencies", "next")
557
+ return "react" if package_json.dig("dependencies", "react")
558
+ return "typescript" if (path / "tsconfig.json").exist?
559
+
560
+ return "javascript"
561
+ end
562
+
563
+ "unknown"
564
+ rescue StandardError
565
+ "unknown"
566
+ end
567
+
568
+ # Collect Rails-specific project information
569
+ def collect_rails_project_info
570
+ rails_info = {}
571
+
572
+ # Database configuration
573
+ if @project&.path && (Pathname.new(@project.path) / "config" / "database.yml").exist?
574
+ begin
575
+ require "yaml"
576
+ db_config = YAML.load_file(Pathname.new(@project.path) / "config" / "database.yml")
577
+ rails_info[:database] = {
578
+ adapter: db_config.dig("development", "adapter"),
579
+ name: db_config.dig("development", "database")
580
+ }
581
+ rescue StandardError
582
+ # Ignore database config parsing errors
583
+ end
584
+ end
585
+
586
+ rails_info
587
+ end
588
+
589
+ # Collect JavaScript/Node.js project information
590
+ def collect_js_project_info
591
+ js_info = {}
592
+
593
+ if @project&.path && (Pathname.new(@project.path) / "package.json").exist?
594
+ begin
595
+ package_json = JSON.parse((Pathname.new(@project.path) / "package.json").read)
596
+ js_info[:package_manager] = detect_package_manager
597
+ js_info[:scripts] = package_json["scripts"] || {}
598
+ js_info[:dependencies] = package_json["dependencies"]&.keys || []
599
+ js_info[:dev_dependencies] = package_json["devDependencies"]&.keys || []
600
+ rescue StandardError
601
+ # Ignore package.json parsing errors
602
+ end
603
+ end
604
+
605
+ js_info
606
+ end
607
+
608
+ # Collect Ruby project information
609
+ def collect_ruby_project_info
610
+ ruby_info = {}
611
+
612
+ if @project&.path && (Pathname.new(@project.path) / "Gemfile").exist?
613
+ # Try to detect bundler version and gems, but don't fail if we can't
614
+ begin
615
+ ruby_info[:bundler_version] = `bundle version`.strip.split.last
616
+ rescue StandardError
617
+ # Ignore bundler detection errors
618
+ end
619
+ end
620
+
621
+ ruby_info
622
+ end
623
+
624
+ # Detect package manager for Node.js projects
625
+ def detect_package_manager
626
+ return "pnpm" if @project&.path && (Pathname.new(@project.path) / "pnpm-lock.yaml").exist?
627
+ return "yarn" if @project&.path && (Pathname.new(@project.path) / "yarn.lock").exist?
628
+ return "npm" if @project&.path && (Pathname.new(@project.path) / "package-lock.json").exist?
629
+
630
+ "npm" # default
631
+ end
632
+
633
+ # Check if Rails is available in the environment
634
+ def rails_available?
635
+ result = collect_rails_version
636
+ !result.empty?
637
+ rescue StandardError
638
+ false
639
+ end
640
+
641
+ # Collect Rails version information
642
+ def collect_rails_version
643
+ require "rails"
644
+ { version: Rails::VERSION::STRING }
645
+ rescue LoadError
646
+ {}
647
+ end
648
+
649
+ # Check if Node.js is available
650
+ def node_available?
651
+ !!system("which node > /dev/null 2>&1")
652
+ rescue StandardError
653
+ false
654
+ end
655
+
656
+ # Collect Node.js version information
657
+ def collect_node_version
658
+ return {} unless node_available?
659
+
660
+ output = `node --version 2>/dev/null`.strip
661
+ version = output.gsub(/^v/, "")
662
+ return {} if version.empty?
663
+
664
+ { version: version }
665
+ rescue StandardError
666
+ {}
667
+ end
668
+
669
+ # Collect database information
670
+ def collect_database_info
671
+ db_info = {}
672
+
673
+ # PostgreSQL
674
+ begin
675
+ pg_version = `psql --version 2>/dev/null`.strip
676
+ db_info[:postgresql] = pg_version.split.last if pg_version
677
+ rescue StandardError
678
+ # Ignore PostgreSQL detection errors
679
+ end
680
+
681
+ # MySQL
682
+ begin
683
+ mysql_version = `mysql --version 2>/dev/null`.strip
684
+ db_info[:mysql] = mysql_version.split.find { |part| part.match(/\d+\.\d+/) } if mysql_version
685
+ rescue StandardError
686
+ # Ignore MySQL detection errors
687
+ end
688
+
689
+ # SQLite
690
+ begin
691
+ sqlite_version = `sqlite3 --version 2>/dev/null`.strip
692
+ db_info[:sqlite3] = sqlite_version.split.first if sqlite_version
693
+ rescue StandardError
694
+ # Ignore SQLite detection errors
695
+ end
696
+
697
+ db_info
698
+ end
699
+
700
+ # Alias for collect to match expected interface
701
+ alias collect_all_variables collect
702
+ end
703
+ end
704
+ end
705
+
706
+ # Add deep_merge helper method to Hash class if not already present
707
+ class Hash
708
+ def deep_merge(other_hash)
709
+ merge(other_hash) do |_key, oldval, newval|
710
+ oldval.is_a?(Hash) && newval.is_a?(Hash) ? oldval.deep_merge(newval) : newval
711
+ end
712
+ end
713
+ end