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.
- checksums.yaml +7 -0
- data/.gitignore +104 -0
- data/CHANGELOG.md +55 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +81 -0
- data/LICENSE +21 -0
- data/README.md +275 -0
- data/Rakefile +58 -0
- data/bin/sk +9 -0
- data/bin/snakommit +10 -0
- data/lib/snakommit/cli.rb +371 -0
- data/lib/snakommit/config.rb +154 -0
- data/lib/snakommit/git.rb +212 -0
- data/lib/snakommit/hooks.rb +258 -0
- data/lib/snakommit/performance.rb +328 -0
- data/lib/snakommit/prompt.rb +472 -0
- data/lib/snakommit/templates.rb +146 -0
- data/lib/snakommit/version.rb +5 -0
- data/lib/snakommit.rb +35 -0
- data/snakommit.gemspec +38 -0
- metadata +194 -0
@@ -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
|