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.
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruborg
4
+ # Terminal progress display: named stages, inline progress bar, and spinner.
5
+ # Writes to $stderr so stdout remains clean for --json or piped output.
6
+ # Degrades to plain text lines when output is not a TTY (piped / redirected).
7
+ class Progress
8
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
9
+ BAR_WIDTH = 28
10
+ LINE_WIDTH = 80
11
+
12
+ def initialize(output: $stderr)
13
+ @output = output
14
+ @tty = output.respond_to?(:isatty) && output.isatty
15
+ @spinner_thread = nil
16
+ end
17
+
18
+ # Print a numbered stage header: "[2/3] Label"
19
+ def stage(index, total, label)
20
+ stop_spin
21
+ clear_line if @tty
22
+ @output.puts "[#{index}/#{total}] #{label}"
23
+ end
24
+
25
+ # Start a spinner on the current line for an indeterminate operation.
26
+ # Call stop_spin (or done) to halt it.
27
+ def spin(label)
28
+ stop_spin
29
+ return unless @tty
30
+
31
+ frame = 0
32
+ @spinner_thread = Thread.new do
33
+ loop do
34
+ @output.print "\r #{SPINNER_FRAMES[frame % SPINNER_FRAMES.size]} #{label}"
35
+ frame += 1
36
+ sleep 0.1
37
+ end
38
+ end
39
+ end
40
+
41
+ # Stop the spinner and erase its line.
42
+ def stop_spin
43
+ return unless @spinner_thread
44
+
45
+ @spinner_thread.kill
46
+ @spinner_thread.join(0.2)
47
+ @spinner_thread = nil
48
+ clear_line if @tty
49
+ end
50
+
51
+ # Redraw an inline progress bar. Call once per item in a loop.
52
+ # label is truncated to fit the terminal line.
53
+ def bar(current, total, label = "")
54
+ return unless @tty
55
+
56
+ pct = total.positive? ? (current.to_f / total) : 0
57
+ filled = (BAR_WIDTH * pct).round
58
+ bar_str = filled.positive? ? "#{"=" * (filled - 1)}>" : ""
59
+ bar_str = bar_str.ljust(BAR_WIDTH)
60
+ short_label = truncate_left(label.to_s, 28)
61
+ @output.print "\r [#{bar_str}] #{current}/#{total} #{short_label.ljust(28)}"
62
+ end
63
+
64
+ # Halt any in-progress display and print a completion line.
65
+ def done(label = nil)
66
+ stop_spin
67
+ clear_line if @tty
68
+ @output.puts " ✓ #{label}" if label
69
+ end
70
+
71
+ private
72
+
73
+ def clear_line
74
+ @output.print "\r#{" " * LINE_WIDTH}\r"
75
+ end
76
+
77
+ def truncate_left(str, max)
78
+ str.length > max ? "...#{str[-(max - 3)..]}" : str
79
+ end
80
+ end
81
+ end
@@ -6,11 +6,13 @@ module Ruborg
6
6
  class Repository
7
7
  attr_reader :path, :borg_path
8
8
 
9
- def initialize(path, passphrase: nil, borg_options: {}, borg_path: nil, logger: nil)
9
+ def initialize(path, passphrase: nil, borg_options: {}, borg_path: nil, lock_wait: nil, logger: nil)
10
+ @original_path = path
10
11
  @path = validate_repo_path(path)
11
12
  @passphrase = passphrase
12
13
  @borg_options = borg_options
13
14
  @borg_path = validate_borg_path(borg_path || "borg")
15
+ @lock_wait = lock_wait&.to_i
14
16
  @logger = logger
15
17
  end
16
18
 
@@ -18,6 +20,37 @@ module Ruborg
18
20
  File.directory?(@path) && File.exist?(File.join(@path, "config"))
19
21
  end
20
22
 
23
+ MINIMUM_BORG_VERSION = "1.4.0"
24
+
25
+ def locked?
26
+ File.exist?(File.join(@path, "lock.exclusive")) ||
27
+ File.exist?(File.join(@path, "lock.roster"))
28
+ end
29
+
30
+ def break_lock
31
+ raise BorgError, "Repository does not exist at #{@path}" unless exists?
32
+
33
+ check_borg_version!
34
+ cmd = [@borg_path, "break-lock", @path]
35
+ execute_borg_command(cmd)
36
+ @logger&.info("Lock broken for repository at #{@path}")
37
+ end
38
+
39
+ def force_break_lock
40
+ raise BorgError, "Repository does not exist at #{@path}" unless exists?
41
+
42
+ require "fileutils"
43
+ removed = %w[lock.exclusive lock.roster].select do |name|
44
+ target = File.join(@path, name)
45
+ next false unless File.exist?(target)
46
+
47
+ FileUtils.rm_rf(target)
48
+ true
49
+ end
50
+ @logger&.info("Force-removed lock files at #{@path}: #{removed.join(", ")}")
51
+ removed
52
+ end
53
+
21
54
  def create
22
55
  raise BorgError, "Repository already exists at #{@path}" if exists?
23
56
 
@@ -278,62 +311,67 @@ module Ruborg
278
311
  nil # Failed to parse, skip this archive
279
312
  end
280
313
 
314
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
281
315
  def get_archives_grouped_by_source_dir
282
316
  require "json"
283
317
  require "time"
284
318
  require "open3"
285
319
 
286
- # Get list of all archives
287
320
  cmd = [@borg_path, "list", @path, "--json"]
288
321
  env = build_borg_env
289
322
 
290
323
  stdout, stderr, status = Open3.capture3(env, *cmd)
291
324
  raise BorgError, "Failed to list archives: #{stderr}" unless status.success?
292
325
 
293
- json_data = JSON.parse(stdout)
294
- archives = json_data["archives"] || []
295
-
296
- # Group archives by source directory from metadata
326
+ archives = JSON.parse(stdout)["archives"] || []
327
+ cache = ArchiveCache.new(@original_path).fetch
297
328
  archives_by_source = Hash.new { |h, k| h[k] = [] }
298
329
 
299
330
  archives.each do |archive|
300
331
  archive_name = archive["name"]
301
-
302
- # Get archive info to read comment (metadata)
303
- info_cmd = [@borg_path, "info", "#{@path}::#{archive_name}", "--json"]
304
- info_stdout, _, info_status = Open3.capture3(env, *info_cmd)
305
-
306
- unless info_status.success?
307
- # If we can't get info, put in legacy group
308
- archives_by_source[""] << {
309
- name: archive_name,
310
- time: Time.parse(archive["time"])
311
- }
312
- next
313
- end
314
-
315
- info_data = JSON.parse(info_stdout)
316
- comment = info_data.dig("archives", 0, "comment") || ""
317
-
318
- # Parse source_dir from comment
319
- # Format: path|||size|||hash|||source_dir
320
- source_dir = if comment.include?("|||")
321
- parts = comment.split("|||")
322
- parts.length >= 4 ? (parts[3] || "") : ""
332
+ archive_time = Time.parse(archive["time"])
333
+
334
+ metadata = if (cached = cache[archive_name])
335
+ cached
336
+ else
337
+ info_cmd = [@borg_path, "info", "#{@path}::#{archive_name}", "--json"]
338
+ info_stdout, _, info_status = Open3.capture3(env, *info_cmd)
339
+
340
+ if info_status.success?
341
+ comment = JSON.parse(info_stdout).dig("archives", 0, "comment") || ""
342
+ parsed = parse_archive_comment(comment)
343
+ cache.store(archive_name, parsed)
344
+ parsed
323
345
  else
324
- ""
346
+ cache.store(archive_name, { path: "", size: 0, hash: "", source_dir: "" })
347
+ { path: "", size: 0, hash: "", source_dir: "" }
325
348
  end
349
+ end
326
350
 
327
- archives_by_source[source_dir] << {
328
- name: archive_name,
329
- time: Time.parse(archive["time"])
330
- }
351
+ archives_by_source[metadata[:source_dir] || ""] << { name: archive_name, time: archive_time }
331
352
  end
332
353
 
354
+ cache.save_if_changed
333
355
  archives_by_source
334
356
  rescue JSON::ParserError => e
335
357
  raise BorgError, "Failed to parse archive metadata: #{e.message}"
336
358
  end
359
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
360
+
361
+ def parse_archive_comment(comment)
362
+ if comment.include?("|||")
363
+ parts = comment.split("|||")
364
+ if parts.length >= 4
365
+ { path: parts[0], size: parts[1].to_i, hash: parts[2] || "", source_dir: parts[3] || "" }
366
+ elsif parts.length >= 3
367
+ { path: parts[0], size: parts[1].to_i, hash: parts[2] || "", source_dir: "" }
368
+ else
369
+ { path: parts[0], size: 0, hash: parts[1] || "", source_dir: "" }
370
+ end
371
+ else
372
+ { path: comment, size: 0, hash: "", source_dir: "" }
373
+ end
374
+ end
337
375
 
338
376
  def prune_per_directory_standard(retention_policy)
339
377
  # Apply standard retention policies (keep_daily, etc.) per source directory
@@ -477,6 +515,21 @@ module Ruborg
477
515
  match[1]
478
516
  end
479
517
 
518
+ # Get Borg path (full path to executable)
519
+ def self.borg_path(borg_command = "borg")
520
+ # If it's an absolute or relative path, expand it
521
+ return File.expand_path(borg_command) if borg_command.include?("/")
522
+
523
+ # Otherwise, search in PATH
524
+ ENV["PATH"].split(File::PATH_SEPARATOR).each do |directory|
525
+ path = File.join(directory, borg_command)
526
+ return path if File.executable?(path)
527
+ end
528
+
529
+ # Not found in PATH, return the command as-is
530
+ borg_command
531
+ end
532
+
480
533
  # Execute borg version command (extracted for testing)
481
534
  def self.execute_version_command(borg_path = "borg")
482
535
  require "open3"
@@ -572,6 +625,26 @@ module Ruborg
572
625
  borg_path
573
626
  end
574
627
 
628
+ def inject_lock_wait(cmd)
629
+ return cmd if @lock_wait.nil? || cmd[1] == "break-lock"
630
+
631
+ [cmd[0], "--lock-wait", @lock_wait.to_s] + cmd[1..]
632
+ end
633
+
634
+ def check_borg_version!
635
+ version = self.class.borg_version(@borg_path)
636
+ return if version_sufficient?(version, MINIMUM_BORG_VERSION)
637
+
638
+ raise BorgError,
639
+ "Borg #{MINIMUM_BORG_VERSION}+ is required but found #{version}. Please upgrade Borg."
640
+ end
641
+
642
+ def version_sufficient?(actual, minimum)
643
+ actual_parts = actual.split(".").map(&:to_i)
644
+ minimum_parts = minimum.split(".").map(&:to_i)
645
+ (actual_parts <=> minimum_parts) >= 0
646
+ end
647
+
575
648
  def find_in_path(command)
576
649
  ENV["PATH"].split(File::PATH_SEPARATOR).each do |directory|
577
650
  path = File.join(directory, command)
@@ -595,6 +668,9 @@ module Ruborg
595
668
  env["BORG_RELOCATED_REPO_ACCESS_IS_OK"] = allow_relocated ? "yes" : "no"
596
669
  env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = allow_unencrypted ? "yes" : "no"
597
670
 
671
+ # Inject --lock-wait for all commands except break-lock (which breaks, not acquires)
672
+ cmd = inject_lock_wait(cmd)
673
+
598
674
  # Redirect stdin from /dev/null to prevent interactive prompts
599
675
  result = system(env, *cmd, in: "/dev/null")
600
676
  raise BorgError, "Borg command failed: #{cmd.join(" ")}" unless result
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruborg
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.3"
5
5
  end
data/lib/ruborg.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  require_relative "ruborg/version"
4
4
  require_relative "ruborg/logger"
5
5
  require_relative "ruborg/config"
6
+ require_relative "ruborg/archive_cache"
7
+ require_relative "ruborg/catalog"
8
+ require_relative "ruborg/progress"
6
9
  require_relative "ruborg/repository"
7
10
  require_relative "ruborg/backup"
8
11
  require_relative "ruborg/passbolt"
@@ -13,4 +16,5 @@ module Ruborg
13
16
  class ConfigError < Error; end
14
17
  class BorgError < Error; end
15
18
  class PassboltError < Error; end
19
+ class CatalogError < Error; end
16
20
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruborg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michail Pantelelis
@@ -143,11 +143,14 @@ files:
143
143
  - SECURITY.md
144
144
  - exe/ruborg
145
145
  - lib/ruborg.rb
146
+ - lib/ruborg/archive_cache.rb
146
147
  - lib/ruborg/backup.rb
148
+ - lib/ruborg/catalog.rb
147
149
  - lib/ruborg/cli.rb
148
150
  - lib/ruborg/config.rb
149
151
  - lib/ruborg/logger.rb
150
152
  - lib/ruborg/passbolt.rb
153
+ - lib/ruborg/progress.rb
151
154
  - lib/ruborg/repository.rb
152
155
  - lib/ruborg/version.rb
153
156
  - ruborg.gemspec