ruborg 0.3.1 → 0.4.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.
data/lib/ruborg/backup.rb CHANGED
@@ -3,14 +3,26 @@
3
3
  module Ruborg
4
4
  # Backup operations using Borg
5
5
  class Backup
6
- def initialize(repository, config:)
6
+ def initialize(repository, config:, retention_mode: "standard", repo_name: nil)
7
7
  @repository = repository
8
8
  @config = config
9
+ @retention_mode = retention_mode
10
+ @repo_name = repo_name
9
11
  end
10
12
 
11
13
  def create(name: nil, remove_source: false)
12
14
  raise BorgError, "Repository does not exist" unless @repository.exists?
13
15
 
16
+ if @retention_mode == "per_file"
17
+ create_per_file_archives(name, remove_source)
18
+ else
19
+ create_standard_archive(name, remove_source)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def create_standard_archive(name, remove_source)
14
26
  archive_name = name || Time.now.strftime("%Y-%m-%d_%H-%M-%S")
15
27
  cmd = build_create_command(archive_name)
16
28
 
@@ -19,14 +31,90 @@ module Ruborg
19
31
  remove_source_files if remove_source
20
32
  end
21
33
 
34
+ def create_per_file_archives(name_prefix, remove_source)
35
+ # Collect all files from backup paths
36
+ files_to_backup = collect_files_from_paths(@config.backup_paths, @config.exclude_patterns)
37
+
38
+ raise BorgError, "No files found to backup" if files_to_backup.empty?
39
+
40
+ timestamp = Time.now.strftime("%Y-%m-%d_%H-%M-%S")
41
+
42
+ files_to_backup.each do |file_path|
43
+ # Generate hash-based archive name
44
+ path_hash = generate_path_hash(file_path)
45
+ archive_name = name_prefix || "#{@repo_name}-#{path_hash}-#{timestamp}"
46
+
47
+ # Create archive for single file with original path as comment
48
+ cmd = build_per_file_create_command(archive_name, file_path)
49
+
50
+ execute_borg_command(cmd)
51
+ end
52
+
53
+ # NOTE: remove_source handled per file after successful backup
54
+ remove_source_files if remove_source
55
+ end
56
+
57
+ def collect_files_from_paths(paths, exclude_patterns)
58
+ require "find"
59
+ files = []
60
+
61
+ paths.each do |base_path|
62
+ base_path = File.expand_path(base_path)
63
+
64
+ if File.file?(base_path)
65
+ files << base_path unless excluded?(base_path, exclude_patterns)
66
+ elsif File.directory?(base_path)
67
+ Find.find(base_path) do |path|
68
+ next unless File.file?(path)
69
+ next if excluded?(path, exclude_patterns)
70
+
71
+ files << path
72
+ end
73
+ end
74
+ end
75
+
76
+ files
77
+ end
78
+
79
+ def excluded?(path, patterns)
80
+ patterns.any? do |pattern|
81
+ # Try matching against full path and just the filename
82
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_DOTMATCH) ||
83
+ File.fnmatch?(pattern, File.basename(path), File::FNM_DOTMATCH)
84
+ end
85
+ end
86
+
87
+ def generate_path_hash(file_path)
88
+ require "digest"
89
+ # Use SHA256 and take first 12 characters for uniqueness
90
+ Digest::SHA256.hexdigest(file_path)[0...12]
91
+ end
92
+
93
+ def build_per_file_create_command(archive_name, file_path)
94
+ cmd = [@repository.borg_path, "create"]
95
+ cmd += ["--compression", @config.compression]
96
+
97
+ # Store original path in archive comment for retrieval
98
+ cmd += ["--comment", file_path]
99
+
100
+ cmd << "#{@repository.path}::#{archive_name}"
101
+ cmd << file_path
102
+
103
+ cmd
104
+ end
105
+
106
+ public
107
+
22
108
  def extract(archive_name, destination: ".", path: nil)
23
109
  raise BorgError, "Repository does not exist" unless @repository.exists?
24
110
 
25
- cmd = ["borg", "extract", "#{@repository.path}::#{archive_name}"]
111
+ cmd = [@repository.borg_path, "extract", "#{@repository.path}::#{archive_name}"]
26
112
  cmd << path if path
27
113
 
28
114
  # Change to destination directory if specified
29
- if destination != "."
115
+ if destination == "."
116
+ execute_borg_command(cmd)
117
+ else
30
118
  require "fileutils"
31
119
 
32
120
  # Validate and normalize destination path
@@ -36,8 +124,6 @@ module Ruborg
36
124
  Dir.chdir(validated_dest) do
37
125
  execute_borg_command(cmd)
38
126
  end
39
- else
40
- execute_borg_command(cmd)
41
127
  end
42
128
  end
43
129
 
@@ -46,14 +132,14 @@ module Ruborg
46
132
  end
47
133
 
48
134
  def delete(archive_name)
49
- cmd = ["borg", "delete", "#{@repository.path}::#{archive_name}"]
135
+ cmd = [@repository.borg_path, "delete", "#{@repository.path}::#{archive_name}"]
50
136
  execute_borg_command(cmd)
51
137
  end
52
138
 
53
139
  private
54
140
 
55
141
  def build_create_command(archive_name)
56
- cmd = ["borg", "create"]
142
+ cmd = [@repository.borg_path, "create"]
57
143
  cmd += ["--compression", @config.compression]
58
144
 
59
145
  @config.exclude_patterns.each do |pattern|
@@ -77,7 +163,7 @@ module Ruborg
77
163
  env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = "yes"
78
164
 
79
165
  result = system(env, *cmd, in: "/dev/null")
80
- raise BorgError, "Borg command failed: #{cmd.join(' ')}" unless result
166
+ raise BorgError, "Borg command failed: #{cmd.join(" ")}" unless result
81
167
 
82
168
  result
83
169
  end
@@ -95,9 +181,7 @@ module Ruborg
95
181
  end
96
182
 
97
183
  # Security check: ensure path hasn't been tampered with
98
- unless File.exist?(real_path)
99
- next
100
- end
184
+ next unless File.exist?(real_path)
101
185
 
102
186
  # Additional safety: don't delete root or system directories
103
187
  if real_path == "/" || real_path.start_with?("/bin", "/sbin", "/usr", "/etc", "/sys", "/proc")
@@ -132,8 +216,9 @@ module Ruborg
132
216
 
133
217
  paths.map do |path|
134
218
  raise BorgError, "Empty backup path specified" if path.nil? || path.to_s.strip.empty?
219
+
135
220
  File.expand_path(path)
136
221
  end
137
222
  end
138
223
  end
139
- end
224
+ end
data/lib/ruborg/cli.rb CHANGED
@@ -17,7 +17,11 @@ module Ruborg
17
17
  # Try to load config to get log_file setting
18
18
  config_path = options[:config] || "ruborg.yml"
19
19
  if File.exist?(config_path)
20
- config_data = YAML.safe_load_file(config_path, permitted_classes: [Symbol], aliases: true) rescue {}
20
+ config_data = begin
21
+ YAML.safe_load_file(config_path, permitted_classes: [Symbol], aliases: true)
22
+ rescue StandardError
23
+ {}
24
+ end
21
25
  log_path = config_data["log_file"]
22
26
  end
23
27
  end
@@ -46,16 +50,11 @@ module Ruborg
46
50
  desc "backup", "Create a backup using configuration file"
47
51
  option :name, type: :string, desc: "Archive name"
48
52
  option :remove_source, type: :boolean, default: false, desc: "Remove source files after successful backup"
49
- option :all, type: :boolean, default: false, desc: "Backup all repositories (multi-repo config only)"
53
+ option :all, type: :boolean, default: false, desc: "Backup all repositories"
50
54
  def backup
51
55
  @logger.info("Starting backup operation with config: #{options[:config]}")
52
56
  config = Config.new(options[:config])
53
-
54
- if config.multi_repo?
55
- backup_multi_repo(config)
56
- else
57
- backup_single_repo(config)
58
- end
57
+ backup_repositories(config)
59
58
  rescue Error => e
60
59
  @logger.error("Backup failed: #{e.message}")
61
60
  error_exit(e)
@@ -65,15 +64,26 @@ module Ruborg
65
64
  def list
66
65
  @logger.info("Listing archives in repository")
67
66
  config = Config.new(options[:config])
68
- passphrase = fetch_passphrase_from_config(config)
69
67
 
70
- repo = Repository.new(config.repository, passphrase: passphrase, borg_options: config.borg_options)
68
+ raise ConfigError, "Please specify --repository" unless options[:repository]
69
+
70
+ repo_config = config.get_repository(options[:repository])
71
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
72
+
73
+ global_settings = config.global_settings
74
+ merged_config = global_settings.merge(repo_config)
75
+ passphrase = fetch_passphrase_for_repo(merged_config)
76
+ borg_opts = merged_config["borg_options"] || {}
77
+ borg_path = merged_config["borg_path"]
78
+
79
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path)
71
80
 
72
81
  # Auto-initialize repository if configured
73
- if config.auto_init? && !repo.exists?
74
- @logger.info("Auto-initializing repository at #{config.repository}")
82
+ auto_init = merged_config["auto_init"] || false
83
+ if auto_init && !repo.exists?
84
+ @logger.info("Auto-initializing repository at #{repo_config["path"]}")
75
85
  repo.create
76
- puts "Repository auto-initialized at #{config.repository}"
86
+ puts "Repository auto-initialized at #{repo_config["path"]}"
77
87
  end
78
88
 
79
89
  repo.list
@@ -90,10 +100,23 @@ module Ruborg
90
100
  restore_target = options[:path] ? "#{options[:path]} from #{archive_name}" : archive_name
91
101
  @logger.info("Restoring #{restore_target} to #{options[:destination]}")
92
102
  config = Config.new(options[:config])
93
- passphrase = fetch_passphrase_from_config(config)
94
103
 
95
- repo = Repository.new(config.repository, passphrase: passphrase, borg_options: config.borg_options)
96
- backup = Backup.new(repo, config: config)
104
+ raise ConfigError, "Please specify --repository" unless options[:repository]
105
+
106
+ repo_config = config.get_repository(options[:repository])
107
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
108
+
109
+ global_settings = config.global_settings
110
+ merged_config = global_settings.merge(repo_config)
111
+ passphrase = fetch_passphrase_for_repo(merged_config)
112
+ borg_opts = merged_config["borg_options"] || {}
113
+ borg_path = merged_config["borg_path"]
114
+
115
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path)
116
+
117
+ # Create backup config wrapper for compatibility
118
+ backup_config = BackupConfig.new(repo_config, merged_config)
119
+ backup = Backup.new(repo, config: backup_config)
97
120
 
98
121
  backup.extract(archive_name, destination: options[:destination], path: options[:path])
99
122
  @logger.info("Successfully restored #{restore_target} to #{options[:destination]}")
@@ -112,15 +135,30 @@ module Ruborg
112
135
  def info
113
136
  @logger.info("Retrieving repository information")
114
137
  config = Config.new(options[:config])
115
- passphrase = fetch_passphrase_from_config(config)
116
138
 
117
- repo = Repository.new(config.repository, passphrase: passphrase, borg_options: config.borg_options)
139
+ # If no repository specified, show summary of all repositories
140
+ unless options[:repository]
141
+ show_repositories_summary(config)
142
+ return
143
+ end
144
+
145
+ repo_config = config.get_repository(options[:repository])
146
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
147
+
148
+ global_settings = config.global_settings
149
+ merged_config = global_settings.merge(repo_config)
150
+ passphrase = fetch_passphrase_for_repo(merged_config)
151
+ borg_opts = merged_config["borg_options"] || {}
152
+ borg_path = merged_config["borg_path"]
153
+
154
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path)
118
155
 
119
156
  # Auto-initialize repository if configured
120
- if config.auto_init? && !repo.exists?
121
- @logger.info("Auto-initializing repository at #{config.repository}")
157
+ auto_init = merged_config["auto_init"] || false
158
+ if auto_init && !repo.exists?
159
+ @logger.info("Auto-initializing repository at #{repo_config["path"]}")
122
160
  repo.create
123
- puts "Repository auto-initialized at #{config.repository}"
161
+ puts "Repository auto-initialized at #{repo_config["path"]}"
124
162
  end
125
163
 
126
164
  repo.info
@@ -130,8 +168,164 @@ module Ruborg
130
168
  error_exit(e)
131
169
  end
132
170
 
171
+ desc "check", "Check repository integrity and compatibility"
172
+ option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower)"
173
+ option :all, type: :boolean, default: false, desc: "Check all repositories"
174
+ def check
175
+ @logger.info("Checking repository compatibility")
176
+ config = Config.new(options[:config])
177
+ global_settings = config.global_settings
178
+
179
+ # Show Borg version first
180
+ borg_version = Repository.borg_version
181
+ puts "\nBorg version: #{borg_version}\n\n"
182
+
183
+ repos_to_check = if options[:all]
184
+ config.repositories
185
+ elsif options[:repository]
186
+ repo_config = config.get_repository(options[:repository])
187
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
188
+
189
+ [repo_config]
190
+ else
191
+ raise ConfigError, "Please specify --repository or --all"
192
+ end
193
+
194
+ repos_to_check.each do |repo_config|
195
+ check_repository(repo_config, global_settings)
196
+ end
197
+ rescue Error => e
198
+ @logger.error("Check failed: #{e.message}")
199
+ error_exit(e)
200
+ end
201
+
133
202
  private
134
203
 
204
+ def check_repository(repo_config, global_settings)
205
+ repo_name = repo_config["name"]
206
+ puts "--- Checking repository: #{repo_name} ---"
207
+ @logger.info("Checking repository: #{repo_name}")
208
+
209
+ merged_config = global_settings.merge(repo_config)
210
+ passphrase = fetch_passphrase_for_repo(merged_config)
211
+ borg_opts = merged_config["borg_options"] || {}
212
+ borg_path = merged_config["borg_path"]
213
+
214
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path)
215
+
216
+ unless repo.exists?
217
+ puts " ✗ Repository does not exist at #{repo_config["path"]}"
218
+ @logger.error("Repository does not exist: #{repo_name}")
219
+ puts ""
220
+ return
221
+ end
222
+
223
+ # Check compatibility
224
+ compatibility = repo.check_compatibility
225
+ puts " Repository version: #{compatibility[:repository_version]}"
226
+
227
+ if compatibility[:compatible]
228
+ puts " ✓ Compatible with Borg #{compatibility[:borg_version]}"
229
+ @logger.info("Repository #{repo_name} is compatible")
230
+ else
231
+ puts " ✗ INCOMPATIBLE with Borg #{compatibility[:borg_version]}"
232
+ repo_ver = compatibility[:repository_version]
233
+ borg_ver = compatibility[:borg_version]
234
+ puts " Repository version #{repo_ver} cannot be read by Borg #{borg_ver}"
235
+ puts " Please upgrade Borg or migrate the repository"
236
+ @logger.error("Repository #{repo_name} is incompatible with installed Borg version")
237
+ end
238
+
239
+ # Run integrity check if requested
240
+ if options[:verify_data]
241
+ puts " Running integrity check..."
242
+ @logger.info("Running integrity check on #{repo_name}")
243
+ repo.check
244
+ puts " ✓ Integrity check passed"
245
+ @logger.info("Integrity check passed for #{repo_name}")
246
+ end
247
+
248
+ puts ""
249
+ rescue BorgError => e
250
+ puts " ✗ Check failed: #{e.message}"
251
+ @logger.error("Check failed for #{repo_name}: #{e.message}")
252
+ puts ""
253
+ end
254
+
255
+ def show_repositories_summary(config)
256
+ repositories = config.repositories
257
+ global_settings = config.global_settings
258
+
259
+ if repositories.empty?
260
+ puts "No repositories configured."
261
+ return
262
+ end
263
+
264
+ puts "\n═══════════════════════════════════════════════════════════════"
265
+ puts " RUBORG REPOSITORIES SUMMARY"
266
+ puts "═══════════════════════════════════════════════════════════════\n\n"
267
+
268
+ # Show global settings
269
+ puts "Global Settings:"
270
+ puts " Compression: #{global_settings["compression"] || "lz4 (default)"}"
271
+ puts " Encryption: #{global_settings["encryption"] || "repokey (default)"}"
272
+ puts " Auto-init: #{global_settings["auto_init"] || false}"
273
+ puts " Retention: #{format_retention(global_settings["retention"])}" if global_settings["retention"]
274
+ puts ""
275
+
276
+ puts "Configured Repositories (#{repositories.size}):"
277
+ puts "─────────────────────────────────────────────────────────────────\n\n"
278
+
279
+ repositories.each_with_index do |repo, index|
280
+ merged_config = global_settings.merge(repo)
281
+
282
+ puts "#{index + 1}. #{repo["name"]}"
283
+ puts " Path: #{repo["path"]}"
284
+ puts " Description: #{repo["description"]}" if repo["description"]
285
+
286
+ # Show repo-specific overrides
287
+ puts " Compression: #{repo["compression"]}" if repo["compression"]
288
+ puts " Encryption: #{repo["encryption"]}" if repo["encryption"]
289
+ puts " Auto-init: #{repo["auto_init"]}" unless repo["auto_init"].nil?
290
+ if repo["retention"]
291
+ puts " Retention: #{format_retention(repo["retention"])}"
292
+ elsif merged_config["retention"]
293
+ puts " Retention: #{format_retention(merged_config["retention"])} (global)"
294
+ end
295
+
296
+ # Show sources
297
+ sources = repo["sources"] || []
298
+ puts " Sources (#{sources.size}):"
299
+ sources.each do |source|
300
+ paths = source["paths"] || []
301
+ puts " - #{source["name"]}: #{paths.size} path(s)"
302
+ end
303
+
304
+ puts ""
305
+ end
306
+
307
+ puts "─────────────────────────────────────────────────────────────────"
308
+ puts "Use 'ruborg info --repository NAME' for detailed information\n\n"
309
+ end
310
+
311
+ def format_retention(retention)
312
+ return "none" if retention.nil? || retention.empty?
313
+
314
+ parts = []
315
+ # Count-based retention
316
+ parts << "#{retention["keep_hourly"]}h" if retention["keep_hourly"]
317
+ parts << "#{retention["keep_daily"]}d" if retention["keep_daily"]
318
+ parts << "#{retention["keep_weekly"]}w" if retention["keep_weekly"]
319
+ parts << "#{retention["keep_monthly"]}m" if retention["keep_monthly"]
320
+ parts << "#{retention["keep_yearly"]}y" if retention["keep_yearly"]
321
+
322
+ # Time-based retention
323
+ parts << "within #{retention["keep_within"]}" if retention["keep_within"]
324
+ parts << "last #{retention["keep_last"]}" if retention["keep_last"]
325
+
326
+ parts.empty? ? "none" : parts.join(", ")
327
+ end
328
+
135
329
  def get_passphrase(passphrase, passbolt_id)
136
330
  return passphrase if passphrase
137
331
  return Passbolt.new(resource_id: passbolt_id).get_password if passbolt_id
@@ -139,13 +333,6 @@ module Ruborg
139
333
  nil
140
334
  end
141
335
 
142
- def fetch_passphrase_from_config(config)
143
- passbolt_config = config.passbolt_integration
144
- return nil if passbolt_config.empty?
145
-
146
- Passbolt.new(resource_id: passbolt_config["resource_id"]).get_password
147
- end
148
-
149
336
  def error_exit(error)
150
337
  puts "Error: #{error.message}"
151
338
  exit 1
@@ -168,7 +355,7 @@ module Ruborg
168
355
  unless File.directory?(log_dir)
169
356
  begin
170
357
  FileUtils.mkdir_p(log_dir)
171
- rescue => e
358
+ rescue StandardError => e
172
359
  raise ConfigError, "Cannot create log directory #{log_dir}: #{e.message}"
173
360
  end
174
361
  end
@@ -176,46 +363,18 @@ module Ruborg
176
363
  normalized_path
177
364
  end
178
365
 
179
- # Single repository backup (legacy)
180
- def backup_single_repo(config)
181
- @logger.info("Backing up paths: #{config.backup_paths.join(', ')}")
182
- passphrase = fetch_passphrase_from_config(config)
183
-
184
- repo = Repository.new(config.repository, passphrase: passphrase, borg_options: config.borg_options)
185
-
186
- # Auto-initialize repository if configured
187
- if config.auto_init? && !repo.exists?
188
- @logger.info("Auto-initializing repository at #{config.repository}")
189
- repo.create
190
- puts "Repository auto-initialized at #{config.repository}"
191
- end
192
-
193
- backup = Backup.new(repo, config: config)
194
-
195
- archive_name = options[:name] ? sanitize_archive_name(options[:name]) : Time.now.strftime("%Y-%m-%d_%H-%M-%S")
196
- @logger.info("Creating archive: #{archive_name}")
197
- backup.create(name: archive_name, remove_source: options[:remove_source])
198
- @logger.info("Backup created successfully: #{archive_name}")
199
-
200
- if options[:remove_source]
201
- @logger.info("Removed source files: #{config.backup_paths.join(', ')}")
202
- end
203
-
204
- puts "Backup created successfully"
205
- puts "Source files removed" if options[:remove_source]
206
- end
207
-
208
- # Multi-repository backup
209
- def backup_multi_repo(config)
366
+ # Backup repositories based on options
367
+ def backup_repositories(config)
210
368
  global_settings = config.global_settings
211
369
  repos_to_backup = if options[:all]
212
370
  config.repositories
213
371
  elsif options[:repository]
214
372
  repo_config = config.get_repository(options[:repository])
215
373
  raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
374
+
216
375
  [repo_config]
217
376
  else
218
- raise ConfigError, "Please specify --repository or --all for multi-repo config"
377
+ raise ConfigError, "Please specify --repository or --all"
219
378
  end
220
379
 
221
380
  repos_to_backup.each do |repo_config|
@@ -233,31 +392,52 @@ module Ruborg
233
392
 
234
393
  passphrase = fetch_passphrase_for_repo(merged_config)
235
394
  borg_opts = merged_config["borg_options"] || {}
236
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts)
395
+ borg_path = merged_config["borg_path"]
396
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path)
237
397
 
238
398
  # Auto-initialize if configured
239
399
  auto_init = merged_config["auto_init"] || false
240
400
  if auto_init && !repo.exists?
241
- @logger.info("Auto-initializing repository at #{repo_config['path']}")
401
+ @logger.info("Auto-initializing repository at #{repo_config["path"]}")
242
402
  repo.create
243
- puts "Repository auto-initialized at #{repo_config['path']}"
403
+ puts "Repository auto-initialized at #{repo_config["path"]}"
244
404
  end
245
405
 
406
+ # Get retention mode (defaults to standard)
407
+ retention_mode = merged_config["retention_mode"] || "standard"
408
+
246
409
  # Create backup config wrapper
247
410
  backup_config = BackupConfig.new(repo_config, merged_config)
248
- backup = Backup.new(repo, config: backup_config)
411
+ backup = Backup.new(repo, config: backup_config, retention_mode: retention_mode, repo_name: repo_name)
249
412
 
250
- archive_name = options[:name] ? sanitize_archive_name(options[:name]) : "#{repo_name}-#{Time.now.strftime('%Y-%m-%d_%H-%M-%S')}"
251
- @logger.info("Creating archive: #{archive_name}")
413
+ archive_name = options[:name] ? sanitize_archive_name(options[:name]) : nil
414
+ @logger.info("Creating archive#{"s" if retention_mode == "per_file"}: #{archive_name || "auto-generated"}")
252
415
 
253
416
  sources = repo_config["sources"] || []
254
- @logger.info("Backing up #{sources.size} source(s)")
417
+ @logger.info("Backing up #{sources.size} source(s)#{" in per-file mode" if retention_mode == "per_file"}")
255
418
 
256
419
  backup.create(name: archive_name, remove_source: options[:remove_source])
257
- @logger.info("Backup created successfully: #{archive_name}")
420
+ @logger.info("Backup created successfully")
258
421
 
259
- puts "✓ Backup created: #{archive_name}"
422
+ if retention_mode == "per_file"
423
+ puts "✓ Per-file backups created"
424
+ else
425
+ puts "✓ Backup created: #{archive_name || "auto-generated"}"
426
+ end
260
427
  puts " Sources removed" if options[:remove_source]
428
+
429
+ # Auto-prune if configured and retention policy exists
430
+ auto_prune = merged_config["auto_prune"] || false
431
+ retention_policy = merged_config["retention"]
432
+
433
+ return unless auto_prune && retention_policy && !retention_policy.empty?
434
+
435
+ mode_desc = retention_mode == "per_file" ? "per-file mode" : "standard mode"
436
+ @logger.info("Auto-pruning repository: #{repo_name} (#{mode_desc})")
437
+ puts " Pruning old backups (#{mode_desc})..."
438
+ repo.prune(retention_policy, retention_mode: retention_mode)
439
+ @logger.info("Pruning completed successfully for #{repo_name}")
440
+ puts " ✓ Pruning completed"
261
441
  end
262
442
 
263
443
  def fetch_passphrase_for_repo(repo_config)
@@ -272,16 +452,15 @@ module Ruborg
272
452
 
273
453
  # Check if name contains at least one valid character before sanitization
274
454
  unless name =~ /[a-zA-Z0-9._-]/
275
- raise ConfigError, "Invalid archive name: must contain at least one valid character (alphanumeric, dot, dash, or underscore)"
455
+ raise ConfigError,
456
+ "Invalid archive name: must contain at least one valid character (alphanumeric, dot, dash, or underscore)"
276
457
  end
277
458
 
278
459
  # Allow only alphanumeric, dash, underscore, and dot
279
- sanitized = name.gsub(/[^a-zA-Z0-9._-]/, '_')
280
-
281
- sanitized
460
+ name.gsub(/[^a-zA-Z0-9._-]/, "_")
282
461
  end
283
462
 
284
- # Wrapper class to adapt multi-repo config to existing Backup class
463
+ # Wrapper class to adapt repository config to existing Backup class
285
464
  class BackupConfig
286
465
  def initialize(repo_config, merged_settings)
287
466
  @repo_config = repo_config
@@ -299,9 +478,9 @@ module Ruborg
299
478
  patterns = []
300
479
  sources = @repo_config["sources"] || []
301
480
  sources.each do |source|
302
- patterns += (source["exclude"] || [])
481
+ patterns += source["exclude"] || []
303
482
  end
304
- patterns += (@merged_settings["exclude_patterns"] || [])
483
+ patterns += @merged_settings["exclude_patterns"] || []
305
484
  patterns.uniq
306
485
  end
307
486
 
@@ -314,4 +493,4 @@ module Ruborg
314
493
  end
315
494
  end
316
495
  end
317
- end
496
+ end