ruborg 0.8.1 → 0.9.3

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/cli.rb CHANGED
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
+ require "json"
4
5
 
5
6
  module Ruborg
6
7
  # Command-line interface for ruborg
7
8
  class CLI < Thor
9
+ DEFAULT_LOCK_WAIT = 300
8
10
  class_option :config, type: :string, default: "ruborg.yml", desc: "Path to configuration file"
9
11
  class_option :log, type: :string, desc: "Path to log file"
10
12
  class_option :repository, type: :string, aliases: "-r", desc: "Repository name (for multi-repo configs)"
@@ -74,11 +76,7 @@ module Ruborg
74
76
  merged_config = global_settings.merge(repo_config)
75
77
  validate_hostname(merged_config)
76
78
  passphrase = fetch_passphrase_for_repo(merged_config)
77
- borg_opts = merged_config["borg_options"] || {}
78
- borg_path = merged_config["borg_path"]
79
-
80
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
81
- logger: @logger)
79
+ repo = build_repo(repo_config["path"], merged_config, passphrase)
82
80
 
83
81
  # Auto-initialize repository if configured
84
82
  # Use strict boolean checking: only true enables, everything else disables
@@ -121,11 +119,7 @@ module Ruborg
121
119
  merged_config = global_settings.merge(repo_config)
122
120
  validate_hostname(merged_config)
123
121
  passphrase = fetch_passphrase_for_repo(merged_config)
124
- borg_opts = merged_config["borg_options"] || {}
125
- borg_path = merged_config["borg_path"]
126
-
127
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
128
- logger: @logger)
122
+ repo = build_repo(repo_config["path"], merged_config, passphrase)
129
123
 
130
124
  # Create backup config wrapper for compatibility
131
125
  backup_config = BackupConfig.new(repo_config, merged_config)
@@ -161,11 +155,7 @@ module Ruborg
161
155
  global_settings = config.global_settings
162
156
  merged_config = global_settings.merge(repo_config)
163
157
  passphrase = fetch_passphrase_for_repo(merged_config)
164
- borg_opts = merged_config["borg_options"] || {}
165
- borg_path = merged_config["borg_path"]
166
-
167
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
168
- logger: @logger)
158
+ repo = build_repo(repo_config["path"], merged_config, passphrase)
169
159
 
170
160
  # Auto-initialize repository if configured
171
161
  # Use strict boolean checking: only true enables, everything else disables
@@ -184,8 +174,23 @@ module Ruborg
184
174
  raise
185
175
  end
186
176
 
187
- desc "validate", "Validate configuration file for errors and type issues"
188
- def validate_config
177
+ desc "validate TYPE", "Validate configuration file or repository (TYPE: config or repo)"
178
+ option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower, only for 'repo' type)"
179
+ option :all, type: :boolean, default: false, desc: "Validate all repositories (only for 'repo' type)"
180
+ def validate(type)
181
+ case type
182
+ when "config"
183
+ validate_config_implementation
184
+ when "repo"
185
+ validate_repo_implementation
186
+ else
187
+ raise ConfigError, "Invalid validation type: #{type}. Use 'config' or 'repo'"
188
+ end
189
+ end
190
+
191
+ private
192
+
193
+ def validate_config_implementation
189
194
  @logger.info("Validating configuration file: #{options[:config]}")
190
195
  config = Config.new(options[:config])
191
196
 
@@ -201,6 +206,7 @@ module Ruborg
201
206
  errors.concat(validate_boolean_setting(global_settings, "auto_init", "global"))
202
207
  errors.concat(validate_boolean_setting(global_settings, "auto_prune", "global"))
203
208
  errors.concat(validate_boolean_setting(global_settings, "allow_remove_source", "global"))
209
+ errors.concat(validate_boolean_setting(global_settings, "skip_hash_check", "global"))
204
210
 
205
211
  # Validate borg_options booleans
206
212
  if global_settings["borg_options"]
@@ -214,6 +220,7 @@ module Ruborg
214
220
  errors.concat(validate_boolean_setting(repo, "auto_init", repo_name))
215
221
  errors.concat(validate_boolean_setting(repo, "auto_prune", repo_name))
216
222
  errors.concat(validate_boolean_setting(repo, "allow_remove_source", repo_name))
223
+ errors.concat(validate_boolean_setting(repo, "skip_hash_check", repo_name))
217
224
 
218
225
  if repo["borg_options"]
219
226
  warnings.concat(validate_borg_option(repo["borg_options"], "allow_relocated_repo", repo_name))
@@ -257,11 +264,203 @@ module Ruborg
257
264
  raise
258
265
  end
259
266
 
260
- desc "version", "Show ruborg version"
267
+ def validate_repo_implementation
268
+ @logger.info("Validating repository compatibility")
269
+ config = Config.new(options[:config])
270
+ global_settings = config.global_settings
271
+ validate_hostname(global_settings)
272
+
273
+ # Show Borg version first
274
+ borg_version = Repository.borg_version
275
+ puts "\nBorg version: #{borg_version}\n\n"
276
+
277
+ repos_to_validate = if options[:all]
278
+ config.repositories
279
+ elsif options[:repository]
280
+ repo_config = config.get_repository(options[:repository])
281
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
282
+
283
+ [repo_config]
284
+ else
285
+ raise ConfigError, "Please specify --repository or --all"
286
+ end
287
+
288
+ repos_to_validate.each do |repo_config|
289
+ validate_repository(repo_config, global_settings)
290
+ end
291
+ rescue Error => e
292
+ @logger.error("Validation failed: #{e.message}")
293
+ raise
294
+ end
295
+
296
+ def validate_repository(repo_config, global_settings)
297
+ repo_name = repo_config["name"]
298
+ puts "--- Validating repository: #{repo_name} ---"
299
+ @logger.info("Validating repository: #{repo_name}")
300
+
301
+ merged_config = global_settings.merge(repo_config)
302
+ validate_hostname(merged_config)
303
+ passphrase = fetch_passphrase_for_repo(merged_config)
304
+ repo = build_repo(repo_config["path"], merged_config, passphrase)
305
+
306
+ unless repo.exists?
307
+ puts " ✗ Repository does not exist at #{repo_config["path"]}"
308
+ @logger.error("Repository does not exist: #{repo_name}")
309
+ puts ""
310
+ return
311
+ end
312
+
313
+ # Check compatibility
314
+ compatibility = repo.check_compatibility
315
+ puts " Repository version: #{compatibility[:repository_version]}"
316
+
317
+ if compatibility[:compatible]
318
+ puts " ✓ Compatible with Borg #{compatibility[:borg_version]}"
319
+ @logger.info("Repository #{repo_name} is compatible")
320
+ else
321
+ puts " ✗ INCOMPATIBLE with Borg #{compatibility[:borg_version]}"
322
+ repo_ver = compatibility[:repository_version]
323
+ borg_ver = compatibility[:borg_version]
324
+ puts " Repository version #{repo_ver} cannot be read by Borg #{borg_ver}"
325
+ puts " Please upgrade Borg or migrate the repository"
326
+ @logger.error("Repository #{repo_name} is incompatible with installed Borg version")
327
+ end
328
+
329
+ # Run integrity check if requested
330
+ if options[:verify_data]
331
+ puts " Running integrity check..."
332
+ @logger.info("Running integrity check on #{repo_name}")
333
+ repo.check
334
+ puts " ✓ Integrity check passed"
335
+ @logger.info("Integrity check passed for #{repo_name}")
336
+ end
337
+
338
+ puts ""
339
+ rescue BorgError => e
340
+ puts " ✗ Validation failed: #{e.message}"
341
+ @logger.error("Validation failed for #{repo_name}: #{e.message}")
342
+ puts ""
343
+ end
344
+
345
+ public
346
+
347
+ desc "catalog", "Search or browse the local archive metadata catalog (no borg calls)"
348
+ option :search, type: :string, desc: "Regex pattern to filter by file path"
349
+ option :stats, type: :boolean, default: false, desc: "Show catalog statistics instead of listing entries"
350
+ option :json, type: :boolean, default: false, desc: "Output as JSON"
351
+ def catalog
352
+ config = Config.new(options[:config])
353
+
354
+ raise ConfigError, "Please specify --repository" unless options[:repository]
355
+
356
+ repo_config = config.get_repository(options[:repository])
357
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
358
+
359
+ global_settings = config.global_settings
360
+ merged_config = global_settings.merge(repo_config)
361
+ cat = Catalog.new(repo_config["path"])
362
+
363
+ if options[:stats]
364
+ print_catalog_stats(cat.stats, options[:json])
365
+ elsif options[:search]
366
+ results = cat.search(options[:search])
367
+ print_catalog_entries(results, options[:json])
368
+ else
369
+ print_catalog_entries(cat.list, options[:json])
370
+ end
371
+
372
+ @logger.info("Catalog query on repository '#{merged_config["name"]}'")
373
+ rescue Error => e
374
+ @logger.error("Catalog failed: #{e.message}")
375
+ raise
376
+ end
377
+
378
+ desc "lock", "Check for and optionally break a Borg repository lock"
379
+ option :break, type: :boolean, default: false,
380
+ desc: "Break the lock via borg break-lock (requires --yes)"
381
+ option :force, type: :boolean, default: false,
382
+ desc: "Force-remove lock files directly without invoking borg (requires --yes)"
383
+ option :yes, type: :boolean, default: false, desc: "Confirm the destructive operation"
384
+ def lock
385
+ config = Config.new(options[:config])
386
+
387
+ raise ConfigError, "Please specify --repository" unless options[:repository]
388
+ raise ConfigError, "Use --break or --force, not both" if options[:break] && options[:force]
389
+
390
+ repo_config = config.get_repository(options[:repository])
391
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
392
+
393
+ global_settings = config.global_settings
394
+ merged_config = global_settings.merge(repo_config)
395
+ passphrase = fetch_passphrase_for_repo(merged_config)
396
+ repo = build_repo(repo_config["path"], merged_config, passphrase)
397
+
398
+ unless repo.locked?
399
+ puts "No lock found for repository '#{repo_config["name"]}'"
400
+ @logger.info("Lock check: no lock found for '#{repo_config["name"]}'")
401
+ return
402
+ end
403
+
404
+ warn "Lock detected on repository '#{repo_config["name"]}' (#{repo_config["path"]})"
405
+ @logger.warn("Lock detected on repository '#{repo_config["name"]}'")
406
+
407
+ unless options[:break] || options[:force]
408
+ warn " Run with --break --yes (via borg) or --force --yes (direct removal)."
409
+ exit 1
410
+ end
411
+
412
+ unless options[:yes]
413
+ warn " Add --yes to confirm."
414
+ exit 1
415
+ end
416
+
417
+ if options[:force]
418
+ removed = repo.force_break_lock
419
+ puts "Force-removed lock files for '#{repo_config["name"]}': #{removed.join(", ")}"
420
+ @logger.info("Force-removed lock files for '#{repo_config["name"]}'")
421
+ else
422
+ repo.break_lock
423
+ puts "Lock broken for repository '#{repo_config["name"]}'"
424
+ @logger.info("Lock broken for repository '#{repo_config["name"]}'")
425
+ end
426
+ rescue Error => e
427
+ @logger.error("Lock command failed: #{e.message}")
428
+ raise
429
+ end
430
+
431
+ desc "version", "Show ruborg and borg versions"
261
432
  def version
262
433
  require_relative "version"
263
434
  puts "ruborg #{Ruborg::VERSION}"
264
435
  @logger.info("Version checked: #{Ruborg::VERSION}")
436
+
437
+ begin
438
+ borg_version = Repository.borg_version
439
+ borg_path = Repository.borg_path
440
+ puts "borg #{borg_version} (#{borg_path})"
441
+ @logger.info("Borg version: #{borg_version}, path: #{borg_path}")
442
+ rescue BorgError => e
443
+ puts "borg: not found or not executable"
444
+ @logger.warn("Could not determine Borg version: #{e.message}")
445
+ end
446
+ end
447
+
448
+ desc "check", "DEPRECATED: Use 'ruborg validate repo' instead"
449
+ option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower)"
450
+ option :all, type: :boolean, default: false, desc: "Validate all repositories"
451
+ def check
452
+ puts "\n⚠️ DEPRECATED COMMAND"
453
+ puts "══════════════════════════════════════════════════════════════════\n\n"
454
+ puts "The 'ruborg check' command has been renamed for consistency.\n"
455
+ puts "Please use: ruborg validate repo\n\n"
456
+ puts "Examples:"
457
+ puts " ruborg validate repo --repository documents"
458
+ puts " ruborg validate repo --all"
459
+ puts " ruborg validate repo --repository documents --verify-data\n\n"
460
+ puts "══════════════════════════════════════════════════════════════════\n"
461
+
462
+ @logger.warn("Deprecated command 'check' was called. User should use 'validate repo' instead.")
463
+ exit 1
265
464
  end
266
465
 
267
466
  desc "metadata ARCHIVE", "Get file metadata from an archive"
@@ -279,11 +478,7 @@ module Ruborg
279
478
  merged_config = global_settings.merge(repo_config)
280
479
  validate_hostname(merged_config)
281
480
  passphrase = fetch_passphrase_for_repo(merged_config)
282
- borg_opts = merged_config["borg_options"] || {}
283
- borg_path = merged_config["borg_path"]
284
-
285
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
286
- logger: @logger)
481
+ repo = build_repo(repo_config["path"], merged_config, passphrase)
287
482
 
288
483
  raise BorgError, "Repository does not exist at #{repo_config["path"]}" unless repo.exists?
289
484
 
@@ -310,93 +505,51 @@ module Ruborg
310
505
  raise
311
506
  end
312
507
 
313
- desc "check", "Check repository integrity and compatibility"
314
- option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower)"
315
- option :all, type: :boolean, default: false, desc: "Check all repositories"
316
- def check
317
- @logger.info("Checking repository compatibility")
318
- config = Config.new(options[:config])
319
- global_settings = config.global_settings
320
- validate_hostname(global_settings)
321
-
322
- # Show Borg version first
323
- borg_version = Repository.borg_version
324
- puts "\nBorg version: #{borg_version}\n\n"
325
-
326
- repos_to_check = if options[:all]
327
- config.repositories
328
- elsif options[:repository]
329
- repo_config = config.get_repository(options[:repository])
330
- raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
331
-
332
- [repo_config]
333
- else
334
- raise ConfigError, "Please specify --repository or --all"
335
- end
336
-
337
- repos_to_check.each do |repo_config|
338
- check_repository(repo_config, global_settings)
339
- end
340
- rescue Error => e
341
- @logger.error("Check failed: #{e.message}")
342
- raise
343
- end
344
-
345
508
  private
346
509
 
347
- def check_repository(repo_config, global_settings)
348
- repo_name = repo_config["name"]
349
- puts "--- Checking repository: #{repo_name} ---"
350
- @logger.info("Checking repository: #{repo_name}")
351
-
352
- merged_config = global_settings.merge(repo_config)
353
- validate_hostname(merged_config)
354
- passphrase = fetch_passphrase_for_repo(merged_config)
355
- borg_opts = merged_config["borg_options"] || {}
356
- borg_path = merged_config["borg_path"]
357
-
358
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
359
- logger: @logger)
360
-
361
- unless repo.exists?
362
- puts " ✗ Repository does not exist at #{repo_config["path"]}"
363
- @logger.error("Repository does not exist: #{repo_name}")
364
- puts ""
510
+ def print_catalog_entries(entries, as_json)
511
+ if as_json
512
+ puts JSON.generate(entries.map { |e| stringify_entry(e) })
365
513
  return
366
514
  end
367
515
 
368
- # Check compatibility
369
- compatibility = repo.check_compatibility
370
- puts " Repository version: #{compatibility[:repository_version]}"
516
+ if entries.empty?
517
+ puts "No entries found."
518
+ return
519
+ end
371
520
 
372
- if compatibility[:compatible]
373
- puts " Compatible with Borg #{compatibility[:borg_version]}"
374
- @logger.info("Repository #{repo_name} is compatible")
375
- else
376
- puts " ✗ INCOMPATIBLE with Borg #{compatibility[:borg_version]}"
377
- repo_ver = compatibility[:repository_version]
378
- borg_ver = compatibility[:borg_version]
379
- puts " Repository version #{repo_ver} cannot be read by Borg #{borg_ver}"
380
- puts " Please upgrade Borg or migrate the repository"
381
- @logger.error("Repository #{repo_name} is incompatible with installed Borg version")
521
+ puts "\n#{"FILE PATH".ljust(55)} #{"SIZE".ljust(10)} ARCHIVE"
522
+ puts "-" * 90
523
+ entries.each do |e|
524
+ puts "#{truncate(e[:path].to_s, 55).ljust(55)} #{format_size(e[:size].to_i).ljust(10)} #{e[:archive_name]}"
382
525
  end
526
+ puts "\n#{entries.size} entry/entries."
527
+ end
383
528
 
384
- # Run integrity check if requested
385
- if options[:verify_data]
386
- puts " Running integrity check..."
387
- @logger.info("Running integrity check on #{repo_name}")
388
- repo.check
389
- puts " ✓ Integrity check passed"
390
- @logger.info("Integrity check passed for #{repo_name}")
529
+ def print_catalog_stats(stats, as_json)
530
+ if as_json
531
+ puts JSON.generate(stats)
532
+ return
391
533
  end
392
534
 
393
- puts ""
394
- rescue BorgError => e
395
- puts " ✗ Check failed: #{e.message}"
396
- @logger.error("Check failed for #{repo_name}: #{e.message}")
535
+ puts "\n═══════════════════════════════════════════════════════════════"
536
+ puts " CATALOG STATISTICS"
537
+ puts "═══════════════════════════════════════════════════════════════\n\n"
538
+ puts " Total archives : #{stats[:total_archives]}"
539
+ puts " Unique files : #{stats[:unique_paths]}"
540
+ puts " Source dirs : #{stats[:source_dirs]}"
541
+ puts " Total size : #{format_size(stats[:total_size])}"
397
542
  puts ""
398
543
  end
399
544
 
545
+ def stringify_entry(entry)
546
+ entry.transform_keys(&:to_s)
547
+ end
548
+
549
+ def truncate(str, max)
550
+ str.length > max ? "...#{str[-(max - 3)..]}" : str
551
+ end
552
+
400
553
  def show_repositories_summary(config)
401
554
  repositories = config.repositories
402
555
  global_settings = config.global_settings
@@ -485,7 +638,7 @@ module Ruborg
485
638
  unit_index += 1
486
639
  end
487
640
 
488
- format("%.2f %s", size, units[unit_index])
641
+ "#{format("%.2f", size)} #{units[unit_index]}"
489
642
  end
490
643
 
491
644
  def get_passphrase(passphrase, passbolt_id)
@@ -544,30 +697,31 @@ module Ruborg
544
697
  puts "\n--- Backing up repository: #{repo_name} ---"
545
698
  @logger.info("Backing up repository: #{repo_name}")
546
699
 
547
- # Merge global settings with repo-specific settings (repo-specific takes precedence)
548
700
  merged_config = global_settings.merge(repo_config)
549
701
  validate_hostname(merged_config)
550
702
 
703
+ retention_mode = merged_config["retention_mode"] || "standard"
704
+ auto_prune = merged_config["auto_prune"] == true
705
+ retention_policy = merged_config["retention"]
706
+ will_prune = auto_prune && retention_policy && !retention_policy.empty?
707
+ stage_total = will_prune ? 3 : 2
708
+
709
+ progress = Progress.new
710
+ progress.stage(1, stage_total, "Verifying repository: #{repo_name}")
711
+
551
712
  passphrase = fetch_passphrase_for_repo(merged_config)
552
- borg_opts = merged_config["borg_options"] || {}
553
- borg_path = merged_config["borg_path"]
554
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
555
- logger: @logger)
713
+ lock_wait = (merged_config["lock_wait"] || DEFAULT_LOCK_WAIT).to_i
714
+ repo = build_repo(repo_config["path"], merged_config, passphrase)
556
715
 
557
- # Auto-initialize if configured
558
- # Use strict boolean checking: only true enables, everything else disables
559
- auto_init = merged_config["auto_init"]
560
- auto_init = false unless auto_init == true
716
+ auto_init = merged_config["auto_init"] == true
561
717
  if auto_init && !repo.exists?
562
718
  @logger.info("Auto-initializing repository at #{repo_config["path"]}")
563
719
  repo.create
564
720
  puts "Repository auto-initialized at #{repo_config["path"]}"
565
721
  end
566
722
 
567
- # Get retention mode (defaults to standard)
568
- retention_mode = merged_config["retention_mode"] || "standard"
723
+ wait_for_lock_clear(repo, repo_name, lock_wait, progress)
569
724
 
570
- # Validate remove_source permission with strict type checking
571
725
  if options[:remove_source]
572
726
  allow_remove_source = merged_config["allow_remove_source"]
573
727
  unless allow_remove_source.is_a?(TrueClass)
@@ -578,10 +732,14 @@ module Ruborg
578
732
  end
579
733
  end
580
734
 
581
- # Create backup config wrapper
735
+ skip_hash_check = merged_config["skip_hash_check"] == true
736
+
737
+ backup_label = retention_mode == "per_file" ? "Backing up files (per-file mode)" : "Creating archive"
738
+ progress.stage(2, stage_total, backup_label)
739
+
582
740
  backup_config = BackupConfig.new(repo_config, merged_config)
583
741
  backup = Backup.new(repo, config: backup_config, retention_mode: retention_mode, repo_name: repo_name,
584
- logger: @logger)
742
+ logger: @logger, skip_hash_check: skip_hash_check, progress: progress)
585
743
 
586
744
  archive_name = options[:name] ? sanitize_archive_name(options[:name]) : nil
587
745
  @logger.info("Creating archive#{"s" if retention_mode == "per_file"}: #{archive_name || "auto-generated"}")
@@ -592,27 +750,55 @@ module Ruborg
592
750
  backup.create(name: archive_name, remove_source: options[:remove_source])
593
751
  @logger.info("Backup created successfully")
594
752
 
595
- if retention_mode == "per_file"
596
- puts "✓ Per-file backups created"
597
- else
598
- puts "✓ Backup created: #{archive_name || "auto-generated"}"
599
- end
600
753
  puts " Sources removed" if options[:remove_source]
601
754
 
602
- # Auto-prune if configured and retention policy exists
603
- # Use strict boolean checking: only true enables, everything else disables
604
- auto_prune = merged_config["auto_prune"]
605
- auto_prune = false unless auto_prune == true
606
- retention_policy = merged_config["retention"]
607
-
608
- return unless auto_prune && retention_policy && !retention_policy.empty?
755
+ return unless will_prune
609
756
 
610
757
  mode_desc = retention_mode == "per_file" ? "per-file mode" : "standard mode"
758
+ progress.stage(3, stage_total, "Pruning old archives (#{mode_desc})")
759
+ progress.spin("Pruning...")
611
760
  @logger.info("Auto-pruning repository: #{repo_name} (#{mode_desc})")
612
- puts " Pruning old backups (#{mode_desc})..."
613
761
  repo.prune(retention_policy, retention_mode: retention_mode)
614
762
  @logger.info("Pruning completed successfully for #{repo_name}")
615
- puts "Pruning completed"
763
+ progress.done("Pruning completed")
764
+ end
765
+
766
+ def wait_for_lock_clear(repo, repo_name, lock_wait, progress)
767
+ return unless repo.locked?
768
+
769
+ @logger.warn("Repository '#{repo_name}' is locked — waiting up to #{lock_wait}s")
770
+ elapsed = 0
771
+ interval = 5
772
+
773
+ progress.spin("Repository locked — waiting for lock to clear (0s / #{lock_wait}s)…")
774
+
775
+ while repo.locked? && elapsed < lock_wait
776
+ sleep interval
777
+ elapsed += interval
778
+ progress.spin("Repository locked — waiting for lock to clear (#{elapsed}s / #{lock_wait}s)…")
779
+ end
780
+
781
+ progress.stop_spin
782
+
783
+ if repo.locked?
784
+ raise BorgError,
785
+ "Repository '#{repo_name}' is still locked after #{lock_wait}s. " \
786
+ "Run 'ruborg lock --repository #{repo_name}' to inspect, or " \
787
+ "'ruborg lock --repository #{repo_name} --break --yes' to clear."
788
+ end
789
+
790
+ @logger.info("Lock cleared for '#{repo_name}' after #{elapsed}s")
791
+ end
792
+
793
+ def build_repo(repo_path, merged_config, passphrase)
794
+ Repository.new(
795
+ repo_path,
796
+ passphrase: passphrase,
797
+ borg_options: merged_config["borg_options"] || {},
798
+ borg_path: merged_config["borg_path"],
799
+ lock_wait: merged_config["lock_wait"]&.to_i,
800
+ logger: @logger
801
+ )
616
802
  end
617
803
 
618
804
  def fetch_passphrase_for_repo(repo_config)
data/lib/ruborg/config.rb CHANGED
@@ -41,7 +41,7 @@ module Ruborg
41
41
 
42
42
  def global_settings
43
43
  @data.slice("passbolt", "compression", "encryption", "auto_init", "borg_options", "log_file", "retention",
44
- "auto_prune", "hostname", "allow_remove_source", "borg_path")
44
+ "auto_prune", "hostname", "allow_remove_source", "borg_path", "skip_hash_check", "lock_wait")
45
45
  end
46
46
 
47
47
  private
@@ -54,12 +54,12 @@ module Ruborg
54
54
  # Valid configuration keys at each level
55
55
  VALID_GLOBAL_KEYS = %w[
56
56
  hostname compression encryption auto_init auto_prune allow_remove_source
57
- log_file borg_path passbolt borg_options retention repositories
57
+ log_file borg_path passbolt borg_options retention repositories skip_hash_check lock_wait
58
58
  ].freeze
59
59
 
60
60
  VALID_REPOSITORY_KEYS = %w[
61
61
  name description path hostname retention_mode passbolt retention sources
62
- compression encryption auto_init auto_prune borg_options allow_remove_source
62
+ compression encryption auto_init auto_prune borg_options allow_remove_source skip_hash_check lock_wait
63
63
  ].freeze
64
64
 
65
65
  VALID_SOURCE_KEYS = %w[name paths exclude].freeze
@@ -122,8 +122,9 @@ module Ruborg
122
122
  errors.concat(validate_boolean_config(@data, "auto_init", "global"))
123
123
  errors.concat(validate_boolean_config(@data, "auto_prune", "global"))
124
124
  errors.concat(validate_boolean_config(@data, "allow_remove_source", "global"))
125
+ errors.concat(validate_boolean_config(@data, "skip_hash_check", "global"))
125
126
 
126
- # Note: borg_options are validated as warnings in CLI validate command, not as errors here
127
+ # NOTE: borg_options are validated as warnings in CLI validate command, not as errors here
127
128
 
128
129
  # Validate global passbolt
129
130
  errors.concat(validate_passbolt_config(@data["passbolt"], "global")) if @data["passbolt"]
@@ -151,6 +152,7 @@ module Ruborg
151
152
  errors.concat(validate_boolean_config(repo, "auto_init", repo_name))
152
153
  errors.concat(validate_boolean_config(repo, "auto_prune", repo_name))
153
154
  errors.concat(validate_boolean_config(repo, "allow_remove_source", repo_name))
155
+ errors.concat(validate_boolean_config(repo, "skip_hash_check", repo_name))
154
156
 
155
157
  # Validate retention_mode
156
158
  if repo["retention_mode"] && !VALID_RETENTION_MODES.include?(repo["retention_mode"])
@@ -158,7 +160,7 @@ module Ruborg
158
160
  "Must be one of: #{VALID_RETENTION_MODES.join(", ")}"
159
161
  end
160
162
 
161
- # Note: borg_options are validated as warnings in CLI validate command, not as errors here
163
+ # NOTE: borg_options are validated as warnings in CLI validate command, not as errors here
162
164
 
163
165
  errors.concat(validate_passbolt_config(repo["passbolt"], repo_name)) if repo["passbolt"]
164
166