snakommit 0.1.1

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.
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snakommit
4
+ # Command-line interface
5
+ class CLI
6
+ class CLIError < StandardError; end
7
+
8
+ COMMANDS = {
9
+ 'commit' => 'Create a commit using the interactive prompt',
10
+ 'emoji' => 'Toggle emoji display in commit messages',
11
+ 'templates' => 'Manage emoji for commit types',
12
+ 'hooks' => 'Manage Git hooks integration',
13
+ 'update' => 'Check for and install the latest version',
14
+ 'version' => 'Show version information',
15
+ 'help' => 'Show help information'
16
+ }.freeze
17
+
18
+ def initialize
19
+ @monitor = Performance::Monitor.new
20
+ @prompt = @monitor.measure(:init_prompt) { Prompt.new }
21
+ @templates = @monitor.measure(:init_templates) { Templates.new }
22
+ @hooks = @monitor.measure(:init_hooks) { Hooks.new }
23
+ rescue => e
24
+ handle_initialization_error(e)
25
+ end
26
+
27
+ def run(args)
28
+ command = args.shift || 'commit'
29
+
30
+ # Start monitoring execution time
31
+ trace_start = Time.now
32
+
33
+ # Execute the command with performance monitoring
34
+ result = @monitor.measure(:command_execution) do
35
+ execute_command(command, args)
36
+ end
37
+
38
+ # Record command execution metrics if in debug mode
39
+ if ENV['SNAKOMMIT_DEBUG']
40
+ trace_end = Time.now
41
+ puts "\nCommand execution completed in #{(trace_end - trace_start).round(3)}s"
42
+ puts "Performance breakdown:"
43
+ @monitor.report.each { |line| puts " #{line}" }
44
+ end
45
+
46
+ result
47
+ rescue => e
48
+ handle_runtime_error(e)
49
+ end
50
+
51
+ private
52
+
53
+ def execute_command(command, args)
54
+ case command
55
+ when 'commit'
56
+ handle_commit
57
+ when 'version', '-v', '--version'
58
+ show_version
59
+ when 'help', '-h', '--help'
60
+ show_help
61
+ when 'hooks'
62
+ handle_hooks(args)
63
+ when 'templates'
64
+ handle_templates(args)
65
+ when 'emoji'
66
+ handle_emoji_toggle(args)
67
+ when 'update'
68
+ check_for_updates(args.include?('--force'))
69
+ when 'validate-message'
70
+ validate_commit_message(args.first)
71
+ when 'prepare-message'
72
+ prepare_commit_message
73
+ when 'log-commit'
74
+ log_commit(args.first)
75
+ else
76
+ unknown_command(command)
77
+ end
78
+ end
79
+
80
+ def handle_commit
81
+ result = @prompt.commit_flow
82
+
83
+ if result[:error]
84
+ puts "Error: #{result[:error]}"
85
+ exit 1
86
+ elsif result[:success]
87
+ puts "Successfully committed: #{result[:message].split("\n").first}"
88
+ end
89
+ end
90
+
91
+ def show_version
92
+ puts "snakommit version #{Snakommit::VERSION}"
93
+ end
94
+
95
+ def unknown_command(command)
96
+ puts "Unknown command: #{command}"
97
+ show_help
98
+ exit 1
99
+ end
100
+
101
+ def handle_emoji_toggle(args)
102
+ state = args.shift
103
+ if state && ['on', 'off'].include?(state.downcase)
104
+ enable = state.downcase == 'on'
105
+ @templates.toggle_emoji(enable)
106
+ emoji_status = @templates.emoji_enabled? ? 'enabled' : 'disabled'
107
+ puts "Emojis are now #{emoji_status} for commit types"
108
+ else
109
+ puts "Usage: snakommit emoji [on|off]"
110
+ exit 1
111
+ end
112
+ end
113
+
114
+ def show_help
115
+ usage_text = <<~HELP
116
+ snakommit - Interactive conventional commit CLI
117
+
118
+ Usage:
119
+ snakommit [command]
120
+
121
+ Commands:
122
+ commit Create a commit using the interactive prompt (default)
123
+ emoji [on|off] Quick toggle for emoji display in commit messages
124
+ hooks [install|uninstall|status] [hook] Manage Git hooks integration
125
+ templates [command] Manage emoji for commit types
126
+ list List available emoji mappings for commit types
127
+ update <type> <emoji> Update emoji for a specific commit type
128
+ reset Reset all emoji mappings to defaults
129
+ update [--force] Check for and install the latest version
130
+ validate-message <file> Validate a commit message file (used by Git hooks)
131
+ prepare-message Prepare a commit message (used by Git hooks)
132
+ help, -h, --help Show this help message
133
+ version, -v, --version Show version information
134
+
135
+ Examples:
136
+ snakommit Run the interactive commit workflow
137
+ sk emoji on Enable emojis in commit messages
138
+ sk emoji off Disable emojis in commit messages
139
+ sk update Update to the latest version
140
+ snakommit hooks install Install all Git hooks
141
+ snakommit help Show this help message
142
+ HELP
143
+
144
+ puts usage_text
145
+ end
146
+
147
+ def handle_hooks(args)
148
+ subcommand = args.shift || 'status'
149
+ hook_name = args.shift # Optional specific hook name
150
+
151
+ case subcommand
152
+ when 'install'
153
+ if @hooks.install(hook_name)
154
+ puts "Git #{hook_name || 'hooks'} installed successfully"
155
+ else
156
+ puts "Failed to install Git #{hook_name || 'hooks'}"
157
+ exit 1
158
+ end
159
+ when 'uninstall'
160
+ if @hooks.uninstall(hook_name)
161
+ puts "Git #{hook_name || 'hooks'} uninstalled successfully"
162
+ else
163
+ puts "Git #{hook_name || 'hooks'} not found or not installed by snakommit"
164
+ end
165
+ when 'status'
166
+ show_hooks_status(hook_name)
167
+ else
168
+ puts "Unknown hooks subcommand: #{subcommand}"
169
+ show_help
170
+ exit 1
171
+ end
172
+ end
173
+
174
+ def show_hooks_status(hook_name)
175
+ status = @hooks.status
176
+ if hook_name
177
+ puts "#{hook_name}: #{status[hook_name] || 'unknown'}"
178
+ else
179
+ puts "Git hooks status:"
180
+ status.each do |hook, state|
181
+ puts " #{hook}: #{state}"
182
+ end
183
+ end
184
+ end
185
+
186
+ def handle_templates(args)
187
+ subcommand = args.shift || 'list'
188
+
189
+ case subcommand
190
+ when 'list'
191
+ list_emoji_mappings
192
+ when 'update'
193
+ update_emoji_mapping(args)
194
+ when 'reset'
195
+ reset_emoji_mappings
196
+ else
197
+ puts "Unknown templates subcommand: #{subcommand}"
198
+ show_help
199
+ exit 1
200
+ end
201
+ end
202
+
203
+ def list_emoji_mappings
204
+ mappings = @templates.list_emoji_mappings
205
+ puts "Emoji mappings for commit types:"
206
+ mappings.each do |mapping|
207
+ puts " #{mapping[:type]}: #{mapping[:emoji]}"
208
+ end
209
+ end
210
+
211
+ def update_emoji_mapping(args)
212
+ type = args.shift
213
+ emoji = args.shift
214
+
215
+ if type && emoji
216
+ begin
217
+ @templates.update_emoji_mapping(type, emoji)
218
+ puts "Updated emoji for #{type} to #{emoji}"
219
+ rescue Templates::TemplateError => e
220
+ puts "Error: #{e.message}"
221
+ exit 1
222
+ end
223
+ else
224
+ puts "Error: Missing arguments. Usage: snakommit templates update <type> <emoji>"
225
+ exit 1
226
+ end
227
+ end
228
+
229
+ def reset_emoji_mappings
230
+ @templates.reset_emoji_mappings
231
+ puts "Reset all emoji mappings to defaults"
232
+ end
233
+
234
+ def validate_commit_message(file_path)
235
+ unless file_path && File.exist?(file_path)
236
+ puts "Error: Commit message file not specified or not found"
237
+ exit 1
238
+ end
239
+
240
+ message = File.read(file_path)
241
+ # Simple validation - use active template's validation later
242
+ if message.strip.empty? || message.lines.first.to_s.strip.empty?
243
+ puts "Error: Empty commit message"
244
+ exit 1
245
+ end
246
+
247
+ exit 0
248
+ end
249
+
250
+ def prepare_commit_message
251
+ # This would normally invoke the interactive prompt
252
+ # For now, just return a simple message for testing
253
+ puts "chore: automated commit message from snakommit"
254
+ exit 0
255
+ end
256
+
257
+ def log_commit(commit_hash)
258
+ # In the future, this will store commit stats
259
+ exit 0 if commit_hash
260
+ exit 1
261
+ end
262
+
263
+ def check_for_updates(force = false)
264
+ require 'open-uri'
265
+ require 'json'
266
+
267
+ puts "Checking for updates..."
268
+
269
+ begin
270
+ # Get the latest version from RubyGems
271
+ response = nil
272
+ @monitor.measure(:fetch_rubygems) do
273
+ response = URI.open("https://rubygems.org/api/v1/gems/snakommit.json").read
274
+ end
275
+
276
+ data = JSON.parse(response)
277
+ latest_version = data["version"]
278
+ current_version = Snakommit::VERSION
279
+
280
+ # Compare versions
281
+ if force || latest_version > current_version
282
+ update_gem(latest_version, current_version)
283
+ else
284
+ puts "You are already using the latest version (#{current_version})"
285
+ end
286
+ rescue => e
287
+ puts "Failed to check for updates: #{e.message}"
288
+ exit 1
289
+ end
290
+ end
291
+
292
+ # Update to the latest gem version
293
+ def update_gem(latest_version, current_version)
294
+ puts "New version available: #{latest_version} (current: #{current_version})"
295
+
296
+ # Confirm update
297
+ puts "Do you want to update? (Y/n)"
298
+ response = STDIN.gets.strip.downcase
299
+ return if response == 'n'
300
+
301
+ # Run the gem update command with a spinner
302
+ require 'tty-spinner'
303
+ spinner = TTY::Spinner.new("[:spinner] Updating to v#{latest_version}... ", format: :dots)
304
+ spinner.auto_spin
305
+
306
+ begin
307
+ result = @monitor.measure(:gem_update) do
308
+ system("gem install snakommit -v #{latest_version}")
309
+ end
310
+
311
+ if result
312
+ spinner.success("Update successful! Restart to use the new version.")
313
+ else
314
+ spinner.error("Update failed with status code: #{$?.exitstatus}")
315
+ puts "Try running manually: gem install snakommit -v #{latest_version}"
316
+ exit 1
317
+ end
318
+ rescue => e
319
+ spinner.error("Update failed: #{e.message}")
320
+ exit 1
321
+ end
322
+ end
323
+
324
+ # Handle initialization errors in a user-friendly way
325
+ def handle_initialization_error(error)
326
+ case error
327
+ when Prompt::PromptError
328
+ puts "Error initializing prompt: #{error.message}"
329
+ when Templates::TemplateError
330
+ puts "Error initializing templates: #{error.message}"
331
+ when Hooks::HookError
332
+ puts "Error initializing hooks: #{error.message}"
333
+ when Config::ConfigError
334
+ puts "Error loading configuration: #{error.message}"
335
+ when Git::GitError
336
+ puts "Git error: #{error.message}"
337
+ else
338
+ puts "Initialization error: #{error.message}"
339
+ puts "Backtrace:\n #{error.backtrace.join("\n ")}" if ENV['SNAKOMMIT_DEBUG']
340
+ end
341
+
342
+ puts "\nTrying to run snakommit in a non-Git repository? Make sure you're in a valid Git repository."
343
+ puts "For more information, run 'snakommit help'"
344
+ exit 1
345
+ end
346
+
347
+ # Handle runtime errors in a user-friendly way
348
+ def handle_runtime_error(error)
349
+ case error
350
+ when Prompt::PromptError
351
+ puts "Error during prompt: #{error.message}"
352
+ when Templates::TemplateError
353
+ puts "Template error: #{error.message}"
354
+ when Hooks::HookError
355
+ puts "Hook error: #{error.message}"
356
+ when Config::ConfigError
357
+ puts "Configuration error: #{error.message}"
358
+ when Git::GitError
359
+ puts "Git error: #{error.message}"
360
+ when CLIError
361
+ puts "CLI error: #{error.message}"
362
+ else
363
+ puts "Error: #{error.message}"
364
+ puts "Backtrace:\n #{error.backtrace.join("\n ")}" if ENV['SNAKOMMIT_DEBUG']
365
+ end
366
+
367
+ puts "\nFor help, run 'snakommit help'"
368
+ exit 1
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+
6
+ module Snakommit
7
+ # Handles configuration for snakommit
8
+ class Config
9
+ class ConfigError < StandardError; end
10
+
11
+ CONFIG_FILE = File.join(Snakommit.config_dir, 'config.yml')
12
+
13
+ # Default configuration settings
14
+ DEFAULT_CONFIG = {
15
+ 'types' => [
16
+ { 'name' => 'feat', 'description' => 'A new feature' },
17
+ { 'name' => 'fix', 'description' => 'A bug fix' },
18
+ { 'name' => 'docs', 'description' => 'Documentation changes' },
19
+ { 'name' => 'style', 'description' => 'Changes that do not affect the meaning of the code' },
20
+ { 'name' => 'refactor', 'description' => 'A code change that neither fixes a bug nor adds a feature' },
21
+ { 'name' => 'perf', 'description' => 'A code change that improves performance' },
22
+ { 'name' => 'test', 'description' => 'Adding missing tests or correcting existing tests' },
23
+ { 'name' => 'build', 'description' => 'Changes that affect the build system or external dependencies' },
24
+ { 'name' => 'ci', 'description' => 'Changes to our CI configuration files and scripts' },
25
+ { 'name' => 'chore', 'description' => 'Other changes that don\'t modify src or test files' }
26
+ ],
27
+ 'scopes' => [],
28
+ 'max_subject_length' => 100,
29
+ 'max_body_line_length' => 72
30
+ }.freeze
31
+
32
+ # Initialiser les variables de classe
33
+ @config_cache = {}
34
+ @config_last_modified = nil
35
+
36
+ # Load configuration from file, creating default if needed
37
+ # @return [Hash] The configuration hash
38
+ # @raise [ConfigError] If configuration can't be loaded
39
+ def self.load
40
+ create_default_config unless File.exist?(CONFIG_FILE)
41
+
42
+ # Check if config file has been modified since last load
43
+ current_mtime = File.mtime(CONFIG_FILE) rescue nil
44
+
45
+ # Return cached config if it exists and file hasn't been modified
46
+ if @config_cache && @config_last_modified == current_mtime
47
+ return @config_cache.dup
48
+ end
49
+
50
+ # Load and cache the configuration
51
+ @config_cache = YAML.load_file(CONFIG_FILE) || {}
52
+ @config_last_modified = current_mtime
53
+
54
+ # Return a copy to prevent unintentional modifications
55
+ @config_cache.dup
56
+ rescue Errno::EACCES, Errno::ENOENT => e
57
+ raise ConfigError, "Could not load configuration: #{e.message}"
58
+ rescue => e
59
+ raise ConfigError, "Unexpected error loading configuration: #{e.message}"
60
+ end
61
+
62
+ # Create the default configuration file
63
+ # @return [Boolean] True if successful
64
+ # @raise [ConfigError] If default config can't be created
65
+ def self.create_default_config
66
+ # Check if directory exists
67
+ config_dir = File.dirname(CONFIG_FILE)
68
+ unless Dir.exist?(config_dir)
69
+ begin
70
+ FileUtils.mkdir_p(config_dir)
71
+ rescue Errno::EACCES => e
72
+ raise ConfigError, "Permission denied creating config directory: #{e.message}"
73
+ end
74
+ end
75
+
76
+ # Write config file if it doesn't exist
77
+ unless File.exist?(CONFIG_FILE)
78
+ begin
79
+ File.write(CONFIG_FILE, DEFAULT_CONFIG.to_yaml)
80
+
81
+ # Update cache
82
+ @config_cache = DEFAULT_CONFIG.dup
83
+ @config_last_modified = File.mtime(CONFIG_FILE) rescue nil
84
+ rescue Errno::EACCES => e
85
+ raise ConfigError, "Permission denied creating config file: #{e.message}"
86
+ rescue => e
87
+ raise ConfigError, "Unexpected error creating config file: #{e.message}"
88
+ end
89
+ end
90
+
91
+ true
92
+ end
93
+
94
+ # Update configuration values
95
+ # @param updates [Hash] Configuration values to update
96
+ # @return [Hash] The updated configuration
97
+ # @raise [ConfigError] If configuration can't be updated
98
+ def self.update(updates)
99
+ config = load
100
+ config.merge!(updates)
101
+
102
+ # Create a backup of the current configuration
103
+ backup_config if File.exist?(CONFIG_FILE)
104
+
105
+ # Write the updated configuration
106
+ File.write(CONFIG_FILE, config.to_yaml)
107
+
108
+ # Update cache
109
+ @config_cache = config.dup
110
+ @config_last_modified = File.mtime(CONFIG_FILE) rescue nil
111
+
112
+ config
113
+ rescue => e
114
+ raise ConfigError, "Failed to update configuration: #{e.message}"
115
+ end
116
+
117
+ # Get a specific configuration value
118
+ # @param key [String] Configuration key
119
+ # @param default [Object] Default value if key not found
120
+ # @return [Object] Configuration value or default
121
+ def self.get(key, default = nil)
122
+ config = load
123
+ config.fetch(key, default)
124
+ end
125
+
126
+ # Reset configuration to defaults
127
+ # @return [Hash] The default configuration
128
+ def self.reset
129
+ backup_config if File.exist?(CONFIG_FILE)
130
+ File.write(CONFIG_FILE, DEFAULT_CONFIG.to_yaml)
131
+
132
+ # Update cache
133
+ @config_cache = DEFAULT_CONFIG.dup
134
+ @config_last_modified = File.mtime(CONFIG_FILE) rescue nil
135
+
136
+ DEFAULT_CONFIG.dup
137
+ rescue => e
138
+ raise ConfigError, "Failed to reset configuration: #{e.message}"
139
+ end
140
+
141
+ private
142
+
143
+ # Backup the current configuration
144
+ # @return [String] Path to backup file
145
+ def self.backup_config
146
+ backup_file = "#{CONFIG_FILE}.bak"
147
+ FileUtils.cp(CONFIG_FILE, backup_file)
148
+ backup_file
149
+ rescue => e
150
+ warn "Warning: Failed to backup configuration: #{e.message}"
151
+ nil
152
+ end
153
+ end
154
+ end