ruborg 0.9.0 → 0.9.4

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.
@@ -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,48 @@ 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 cache_locked?
31
+ File.exist?(cache_lock_path)
32
+ end
33
+
34
+ def break_lock
35
+ raise BorgError, "Repository does not exist at #{@path}" unless exists?
36
+
37
+ check_borg_version!
38
+ cmd = [@borg_path, "break-lock", @path]
39
+ execute_borg_command(cmd)
40
+ @logger&.info("Lock broken for repository at #{@path}")
41
+ end
42
+
43
+ def force_break_lock
44
+ raise BorgError, "Repository does not exist at #{@path}" unless exists?
45
+
46
+ require "fileutils"
47
+ removed = %w[lock.exclusive lock.roster].select do |name|
48
+ target = File.join(@path, name)
49
+ next false unless File.exist?(target)
50
+
51
+ FileUtils.rm_rf(target)
52
+ true
53
+ end
54
+
55
+ cache_lock = cache_lock_path
56
+ if File.exist?(cache_lock)
57
+ FileUtils.rm_f(cache_lock)
58
+ removed << "cache:lock.exclusive"
59
+ end
60
+
61
+ @logger&.info("Force-removed lock files at #{@path}: #{removed.join(", ")}")
62
+ removed
63
+ end
64
+
21
65
  def create
22
66
  raise BorgError, "Repository already exists at #{@path}" if exists?
23
67
 
@@ -278,62 +322,67 @@ module Ruborg
278
322
  nil # Failed to parse, skip this archive
279
323
  end
280
324
 
325
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
281
326
  def get_archives_grouped_by_source_dir
282
327
  require "json"
283
328
  require "time"
284
329
  require "open3"
285
330
 
286
- # Get list of all archives
287
331
  cmd = [@borg_path, "list", @path, "--json"]
288
332
  env = build_borg_env
289
333
 
290
334
  stdout, stderr, status = Open3.capture3(env, *cmd)
291
335
  raise BorgError, "Failed to list archives: #{stderr}" unless status.success?
292
336
 
293
- json_data = JSON.parse(stdout)
294
- archives = json_data["archives"] || []
295
-
296
- # Group archives by source directory from metadata
337
+ archives = JSON.parse(stdout)["archives"] || []
338
+ cache = ArchiveCache.new(@original_path).fetch
297
339
  archives_by_source = Hash.new { |h, k| h[k] = [] }
298
340
 
299
341
  archives.each do |archive|
300
342
  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] || "") : ""
343
+ archive_time = Time.parse(archive["time"])
344
+
345
+ metadata = if (cached = cache[archive_name])
346
+ cached
347
+ else
348
+ info_cmd = [@borg_path, "info", "#{@path}::#{archive_name}", "--json"]
349
+ info_stdout, _, info_status = Open3.capture3(env, *info_cmd)
350
+
351
+ if info_status.success?
352
+ comment = JSON.parse(info_stdout).dig("archives", 0, "comment") || ""
353
+ parsed = parse_archive_comment(comment)
354
+ cache.store(archive_name, parsed)
355
+ parsed
323
356
  else
324
- ""
357
+ cache.store(archive_name, { path: "", size: 0, hash: "", source_dir: "" })
358
+ { path: "", size: 0, hash: "", source_dir: "" }
325
359
  end
360
+ end
326
361
 
327
- archives_by_source[source_dir] << {
328
- name: archive_name,
329
- time: Time.parse(archive["time"])
330
- }
362
+ archives_by_source[metadata[:source_dir] || ""] << { name: archive_name, time: archive_time }
331
363
  end
332
364
 
365
+ cache.save_if_changed
333
366
  archives_by_source
334
367
  rescue JSON::ParserError => e
335
368
  raise BorgError, "Failed to parse archive metadata: #{e.message}"
336
369
  end
370
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
371
+
372
+ def parse_archive_comment(comment)
373
+ if comment.include?("|||")
374
+ parts = comment.split("|||")
375
+ if parts.length >= 4
376
+ { path: parts[0], size: parts[1].to_i, hash: parts[2] || "", source_dir: parts[3] || "" }
377
+ elsif parts.length >= 3
378
+ { path: parts[0], size: parts[1].to_i, hash: parts[2] || "", source_dir: "" }
379
+ else
380
+ { path: parts[0], size: 0, hash: parts[1] || "", source_dir: "" }
381
+ end
382
+ else
383
+ { path: comment, size: 0, hash: "", source_dir: "" }
384
+ end
385
+ end
337
386
 
338
387
  def prune_per_directory_standard(retention_policy)
339
388
  # Apply standard retention policies (keep_daily, etc.) per source directory
@@ -587,6 +636,39 @@ module Ruborg
587
636
  borg_path
588
637
  end
589
638
 
639
+ def inject_lock_wait(cmd)
640
+ return cmd if @lock_wait.nil? || cmd[1] == "break-lock"
641
+
642
+ [cmd[0], "--lock-wait", @lock_wait.to_s] + cmd[1..]
643
+ end
644
+
645
+ def check_borg_version!
646
+ version = self.class.borg_version(@borg_path)
647
+ return if version_sufficient?(version, MINIMUM_BORG_VERSION)
648
+
649
+ raise BorgError,
650
+ "Borg #{MINIMUM_BORG_VERSION}+ is required but found #{version}. Please upgrade Borg."
651
+ end
652
+
653
+ def version_sufficient?(actual, minimum)
654
+ actual_parts = actual.split(".").map(&:to_i)
655
+ minimum_parts = minimum.split(".").map(&:to_i)
656
+ (actual_parts <=> minimum_parts) >= 0
657
+ end
658
+
659
+ def cache_lock_path
660
+ cache_dir = ENV.fetch("BORG_CACHE_DIR", File.join(Dir.home, ".cache", "borg"))
661
+ repo_id = read_repo_id
662
+ File.join(cache_dir, repo_id, "lock.exclusive")
663
+ end
664
+
665
+ def read_repo_id
666
+ config_file = File.join(@path, "config")
667
+ return "" unless File.exist?(config_file)
668
+
669
+ File.read(config_file).match(/^\s*id\s*=\s*([0-9a-f]+)/)&.captures&.first || ""
670
+ end
671
+
590
672
  def find_in_path(command)
591
673
  ENV["PATH"].split(File::PATH_SEPARATOR).each do |directory|
592
674
  path = File.join(directory, command)
@@ -610,6 +692,9 @@ module Ruborg
610
692
  env["BORG_RELOCATED_REPO_ACCESS_IS_OK"] = allow_relocated ? "yes" : "no"
611
693
  env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = allow_unencrypted ? "yes" : "no"
612
694
 
695
+ # Inject --lock-wait for all commands except break-lock (which breaks, not acquires)
696
+ cmd = inject_lock_wait(cmd)
697
+
613
698
  # Redirect stdin from /dev/null to prevent interactive prompts
614
699
  result = system(env, *cmd, in: "/dev/null")
615
700
  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.9.0"
4
+ VERSION = "0.9.4"
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,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruborg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michail Pantelelis
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-10-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: psych
@@ -144,11 +143,14 @@ files:
144
143
  - SECURITY.md
145
144
  - exe/ruborg
146
145
  - lib/ruborg.rb
146
+ - lib/ruborg/archive_cache.rb
147
147
  - lib/ruborg/backup.rb
148
+ - lib/ruborg/catalog.rb
148
149
  - lib/ruborg/cli.rb
149
150
  - lib/ruborg/config.rb
150
151
  - lib/ruborg/logger.rb
151
152
  - lib/ruborg/passbolt.rb
153
+ - lib/ruborg/progress.rb
152
154
  - lib/ruborg/repository.rb
153
155
  - lib/ruborg/version.rb
154
156
  - ruborg.gemspec
@@ -161,7 +163,6 @@ metadata:
161
163
  source_code_uri: https://github.com/mpantel/ruborg.git
162
164
  changelog_uri: https://github.com/mpantel/ruborg/blob/main/CHANGELOG.md
163
165
  rubygems_mfa_required: 'true'
164
- post_install_message:
165
166
  rdoc_options: []
166
167
  require_paths:
167
168
  - lib
@@ -176,8 +177,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
176
177
  - !ruby/object:Gem::Version
177
178
  version: '0'
178
179
  requirements: []
179
- rubygems_version: 3.5.22
180
- signing_key:
180
+ rubygems_version: 3.7.1
181
181
  specification_version: 4
182
182
  summary: A friendly Ruby frontend for Borg backup
183
183
  test_files: []