ruborg 0.9.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f3c9f239a72ec2f321419eb6f4349c25fe4cec4ca5174c156e2f1d3fe84ec68
4
- data.tar.gz: 4930dbbc8dc2d4e2040628a8c57e62d7bf3ac20ca58a0e7e363a649f6f8d6546
3
+ metadata.gz: b10d4c2ab697830a9ac1b3e4ce1e13de22728577b2c5f7326465dd9ac877d688
4
+ data.tar.gz: 488993366f00681563d850468c28e50bbdeffae07aa2da7e1925451d813ab135
5
5
  SHA512:
6
- metadata.gz: ba9bd867f83d24c88abf435f4b64368b9c414080290747f107a6b5e94db6e82ddba0a59b0f700a4625e689b54724aefceecccc56f5e51f3fc8271be74601bc5e
7
- data.tar.gz: e95dd232d73c7c63968c0105170928d0ab28d689342ad875a81fbc30ac56d508179a1866b476b79125fe43a85fc69cc68baef14a45cd220019be5612ce458bea
6
+ metadata.gz: 33792f380eb30ecbeb8d1036d807b11077fd3ddd55ab22abedcd4e7b7ee8d8aaffe3552e796ca816f396fc947e6c338a3f68a9d2330db041c1608cf2d1ac020a
7
+ data.tar.gz: 23cdd2f090b1a7c860a1568edc266088c59c39ecc54424523fbad3f8e260cb54da33e6400d645ea4df5d22906414a72cc6c7f02b5fae07d3906a8f914e9a10a2
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.4] - 2026-05-09
11
+
12
+ ### Fixed
13
+ - **`ruborg lock` now detects Borg cache locks** — previously, a stale cache lock (`~/.cache/borg/<id>/lock.exclusive`) was invisible to `ruborg lock`, which only checked repository-level lock files. The command now reports both the repository lock and the cache lock state independently, so a cache-only stale lock no longer produces a misleading "No lock found" result.
14
+ - `Repository#cache_locked?` — pure filesystem check on the cache lock; reads the repo ID from `<repo>/config` without invoking Borg or requiring a passphrase
15
+ - `Repository#force_break_lock` — now also removes the cache lock and includes `"cache:lock.exclusive"` in its returned list when removed
16
+ - `borg break-lock` (used by `--break`) already handles both locks atomically — no change needed there
17
+ - Respects `BORG_CACHE_DIR` when set
18
+ - Fixes [#10](https://github.com/mpantel/ruborg/issues/10)
19
+ - **Per-file backup: O(N) `borg info` calls replaced with a single `borg list --format` pass** — previously, building the archive inventory called `borg info` once per archive not yet in the `ArchiveCache`. On large repositories this produced hundreds of sequential Borg subprocesses per run, each briefly acquiring the Borg cache lock, causing cumulative lock timeout errors. The inventory now uses a single `borg list --format '{name} {comment}\n'` call which retrieves name and metadata comment for all archives at once, warming the cache transparently in the same pass (combined warm-and-continue). Subsequent runs hit the cache with zero additional Borg calls.
20
+ - Fixes [#12](https://github.com/mpantel/ruborg/issues/12)
21
+ - **Progress display improvements** — backup runs now give richer real-time feedback (fixes [#14](https://github.com/mpantel/ruborg/issues/14)):
22
+ - **Elapsed time in all spinners**: any step that takes more than 3 seconds shows a live `(Ns)` counter so long-running stages are visually distinguishable from fast ones
23
+ - **Stage 1 is always animated**: the "Verifying repository" stage now spins immediately (passphrase fetch, auto-init, lock-wait) rather than flashing and disappearing
24
+ - **Standard backup file count**: `borg create` runs with `--list --filter AM`; the spinner updates in real time with the count of new/changed files being archived, and the completion line reports the final count (e.g. `✓ Archive created: my-repo-… — 42 new/changed file(s)`)
25
+ - **`Progress#update_spin`**: new method for updating a spinner's label mid-operation without restarting it
26
+
10
27
  ## [0.9.3] - 2026-05-09
11
28
 
12
29
  ### Added
data/README.md CHANGED
@@ -479,11 +479,20 @@ Type: regular file
479
479
 
480
480
  Borg uses lock files to prevent concurrent access. If a backup crashes, stale locks can block all subsequent operations. Use `ruborg lock` to inspect and clear them.
481
481
 
482
+ Borg maintains **two independent locks** — one on the repository itself and one on the local cache (`~/.cache/borg/<id>/lock.exclusive`). `ruborg lock` checks and reports both:
483
+
484
+ ```
485
+ Lock detected on repository 'documents' (/mnt/backups/documents):
486
+ Repository lock : clear
487
+ Cache lock : LOCKED
488
+ Run with --break --yes (via borg) or --force --yes (direct removal).
489
+ ```
490
+
482
491
  ```bash
483
- # Check if a repository is locked (exits 0 = no lock, 1 = locked)
492
+ # Check both repository and cache locks (exits 0 = no lock, 1 = locked)
484
493
  ruborg lock --repository documents
485
494
 
486
- # Break the lock via borg break-lock (requires Borg >= 1.4.0)
495
+ # Break both locks via borg break-lock (requires Borg >= 1.4.0)
487
496
  ruborg lock --repository documents --break --yes
488
497
 
489
498
  # Force-remove lock files directly (no Borg required, last resort)
@@ -857,6 +866,13 @@ repositories:
857
866
 
858
867
  **Performance Note:** Per-file mode creates many archives (one per file). Borg handles this efficiently due to deduplication, but it's best suited for directories with hundreds to thousands of files rather than millions.
859
868
 
869
+ **Progress feedback:** During backup, ruborg shows real-time progress on stderr:
870
+ - All spinner stages display an elapsed-time counter after 3 seconds (`Preparing... (12s)`) so long-running steps are always visible
871
+ - Standard backup mode (`borg create`) streams file-level output and shows a running count of new/changed files in the spinner, with a final summary in the completion line (e.g. `✓ Archive created: my-repo-… — 42 new/changed file(s)`)
872
+ - Per-file mode shows a progress bar with current/total file count
873
+
874
+ **Inventory performance:** At the start of each per-file backup run, ruborg builds an inventory of existing archives using a single `borg list` call. Metadata is cached locally in a `.ruborg_cache.json` file beside the repository — subsequent runs serve the inventory entirely from cache with no additional Borg calls. The first run after introducing a large existing repository will be slower as it warms the cache, but all subsequent runs are fast regardless of how many archives exist.
875
+
860
876
  **Backup vs Retention:** The per-file `retention_mode` only affects how archives are created and pruned. Traditional backup commands still work normally - you can list, restore, and check per-file archives just like standard archives.
861
877
 
862
878
  ### Skip Hash Check for Faster Backups
data/lib/ruborg/backup.rb CHANGED
@@ -32,10 +32,15 @@ module Ruborg
32
32
  print_repository_header
33
33
  @progress&.spin("Creating archive: #{archive_name}")
34
34
 
35
- cmd = build_create_command(archive_name)
36
- execute_borg_command(cmd)
35
+ count = if @progress
36
+ stream_borg_create(archive_name)
37
+ else
38
+ execute_borg_command(build_create_command(archive_name))
39
+ 0
40
+ end
37
41
 
38
- @progress&.done("Archive created: #{archive_name}")
42
+ summary = count.positive? ? " #{count} new/changed file(s)" : ""
43
+ @progress&.done("Archive created: #{archive_name}#{summary}")
39
44
  @logger&.info("[#{@repo_name}] Created archive #{archive_name} with #{@config.backup_paths.size} source(s)")
40
45
 
41
46
  remove_source_files if remove_source
@@ -342,19 +347,43 @@ module Ruborg
342
347
  cmd
343
348
  end
344
349
 
345
- def execute_borg_command(cmd)
350
+ def borg_env
346
351
  env = {}
347
352
  passphrase = @repository.instance_variable_get(:@passphrase)
348
353
  env["BORG_PASSPHRASE"] = passphrase if passphrase
349
354
  env["BORG_RELOCATED_REPO_ACCESS_IS_OK"] = "yes"
350
355
  env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = "yes"
356
+ env
357
+ end
351
358
 
352
- result = system(env, *cmd, in: "/dev/null")
359
+ def execute_borg_command(cmd)
360
+ result = system(borg_env, *cmd, in: "/dev/null")
353
361
  raise BorgError, "Borg command failed: #{cmd.join(" ")}" unless result
354
362
 
355
363
  result
356
364
  end
357
365
 
366
+ # Run borg create with --list --filter AM, streaming stderr to count
367
+ # new/changed files and update the spinner label in real time.
368
+ def stream_borg_create(archive_name)
369
+ require "open3"
370
+
371
+ cmd = build_create_command(archive_name) + ["--list", "--filter", "AM"]
372
+ file_count = 0
373
+
374
+ Open3.popen3(borg_env, *cmd, in: "/dev/null") do |_sin, _sout, stderr, wait_thr|
375
+ stderr.each_line do |line|
376
+ next unless line.match?(/\A[AM] /)
377
+
378
+ file_count += 1
379
+ @progress.update_spin("Creating archive: #{archive_name} — #{file_count} new/changed file(s)")
380
+ end
381
+ raise BorgError, "Borg command failed: #{cmd.join(" ")}" unless wait_thr.value.success?
382
+ end
383
+
384
+ file_count
385
+ end
386
+
358
387
  def remove_single_file(file_path)
359
388
  require "fileutils"
360
389
 
@@ -458,51 +487,41 @@ module Ruborg
458
487
  puts "=" * 60
459
488
  end
460
489
 
461
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
462
490
  def get_existing_archive_names
463
- require "json"
464
491
  require "open3"
465
492
 
466
- cmd = [@repository.borg_path, "list", @repository.path, "--json"]
467
493
  env = {}
468
494
  passphrase = @repository.instance_variable_get(:@passphrase)
469
495
  env["BORG_PASSPHRASE"] = passphrase if passphrase
470
496
  env["BORG_RELOCATED_REPO_ACCESS_IS_OK"] = "yes"
471
497
  env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = "yes"
472
498
 
499
+ # Single borg call: fetch name + comment for every archive at once.
500
+ # Replaces the previous O(N) borg info loop and warms the cache in the same pass.
501
+ cmd = [@repository.borg_path, "list", @repository.path, "--format", "{name} {comment}\n"]
473
502
  stdout, stderr, status = Open3.capture3(env, *cmd)
474
503
  raise BorgError, "Failed to list archives: #{stderr}" unless status.success?
475
504
 
476
- archives = JSON.parse(stdout)["archives"] || []
477
505
  cache = ArchiveCache.new(@repository.path).fetch
506
+ result = {}
478
507
 
479
- result = archives.each_with_object({}) do |archive, hash|
480
- archive_name = archive["name"]
508
+ stdout.each_line do |line|
509
+ archive_name, comment = line.chomp.split(" ", 2)
510
+ next unless archive_name
481
511
 
482
512
  if (cached = cache[archive_name])
483
- hash[archive_name] = cached
513
+ result[archive_name] = cached
484
514
  next
485
515
  end
486
516
 
487
- info_cmd = [@repository.borg_path, "info", "#{@repository.path}::#{archive_name}", "--json"]
488
- info_stdout, _, info_status = Open3.capture3(env, *info_cmd)
489
-
490
- metadata = if info_status.success?
491
- parse_archive_comment(JSON.parse(info_stdout).dig("archives", 0, "comment") || "")
492
- else
493
- { path: "", size: 0, hash: "", source_dir: "" }
494
- end
495
-
517
+ metadata = parse_archive_comment(comment || "")
496
518
  cache.store(archive_name, metadata)
497
- hash[archive_name] = metadata
519
+ result[archive_name] = metadata
498
520
  end
499
521
 
500
522
  cache.save_if_changed
501
523
  result
502
- rescue JSON::ParserError => e
503
- raise BorgError, "Failed to parse archive info: #{e.message}"
504
524
  end
505
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
506
525
 
507
526
  def parse_archive_comment(comment)
508
527
  if comment.include?("|||")
data/lib/ruborg/cli.rb CHANGED
@@ -395,14 +395,19 @@ module Ruborg
395
395
  passphrase = fetch_passphrase_for_repo(merged_config)
396
396
  repo = build_repo(repo_config["path"], merged_config, passphrase)
397
397
 
398
- unless repo.locked?
398
+ repo_locked = repo.locked?
399
+ cache_locked = repo.cache_locked?
400
+
401
+ unless repo_locked || cache_locked
399
402
  puts "No lock found for repository '#{repo_config["name"]}'"
400
403
  @logger.info("Lock check: no lock found for '#{repo_config["name"]}'")
401
404
  return
402
405
  end
403
406
 
404
- warn "Lock detected on repository '#{repo_config["name"]}' (#{repo_config["path"]})"
405
- @logger.warn("Lock detected on repository '#{repo_config["name"]}'")
407
+ warn "Lock detected on repository '#{repo_config["name"]}' (#{repo_config["path"]}):"
408
+ warn " Repository lock : #{repo_locked ? "LOCKED" : "clear"}"
409
+ warn " Cache lock : #{cache_locked ? "LOCKED" : "clear"}"
410
+ @logger.warn("Lock detected on '#{repo_config["name"]}' — repo=#{repo_locked}, cache=#{cache_locked}")
406
411
 
407
412
  unless options[:break] || options[:force]
408
413
  warn " Run with --break --yes (via borg) or --force --yes (direct removal)."
@@ -708,6 +713,7 @@ module Ruborg
708
713
 
709
714
  progress = Progress.new
710
715
  progress.stage(1, stage_total, "Verifying repository: #{repo_name}")
716
+ progress.spin("Preparing...")
711
717
 
712
718
  passphrase = fetch_passphrase_for_repo(merged_config)
713
719
  lock_wait = (merged_config["lock_wait"] || DEFAULT_LOCK_WAIT).to_i
@@ -13,6 +13,8 @@ module Ruborg
13
13
  @output = output
14
14
  @tty = output.respond_to?(:isatty) && output.isatty
15
15
  @spinner_thread = nil
16
+ @spin_label = nil
17
+ @spin_start = nil
16
18
  end
17
19
 
18
20
  # Print a numbered stage header: "[2/3] Label"
@@ -23,21 +25,32 @@ module Ruborg
23
25
  end
24
26
 
25
27
  # Start a spinner on the current line for an indeterminate operation.
28
+ # Appends elapsed time after 3 seconds so long-running steps are visible.
26
29
  # Call stop_spin (or done) to halt it.
27
30
  def spin(label)
28
31
  stop_spin
29
32
  return unless @tty
30
33
 
34
+ @spin_label = label
35
+ @spin_start = Time.now
31
36
  frame = 0
32
37
  @spinner_thread = Thread.new do
33
38
  loop do
34
- @output.print "\r #{SPINNER_FRAMES[frame % SPINNER_FRAMES.size]} #{label}"
39
+ elapsed = (Time.now - @spin_start).to_i
40
+ time_str = elapsed >= 3 ? " (#{elapsed}s)" : ""
41
+ @output.print "\r #{SPINNER_FRAMES[frame % SPINNER_FRAMES.size]} #{@spin_label}#{time_str}"
35
42
  frame += 1
36
43
  sleep 0.1
37
44
  end
38
45
  end
39
46
  end
40
47
 
48
+ # Update the label on a running spinner without restarting it.
49
+ # Safe to call from the main thread while the spinner runs in the background.
50
+ def update_spin(label)
51
+ @spin_label = label
52
+ end
53
+
41
54
  # Stop the spinner and erase its line.
42
55
  def stop_spin
43
56
  return unless @spinner_thread
@@ -27,6 +27,10 @@ module Ruborg
27
27
  File.exist?(File.join(@path, "lock.roster"))
28
28
  end
29
29
 
30
+ def cache_locked?
31
+ File.exist?(cache_lock_path)
32
+ end
33
+
30
34
  def break_lock
31
35
  raise BorgError, "Repository does not exist at #{@path}" unless exists?
32
36
 
@@ -47,6 +51,13 @@ module Ruborg
47
51
  FileUtils.rm_rf(target)
48
52
  true
49
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
+
50
61
  @logger&.info("Force-removed lock files at #{@path}: #{removed.join(", ")}")
51
62
  removed
52
63
  end
@@ -645,6 +656,19 @@ module Ruborg
645
656
  (actual_parts <=> minimum_parts) >= 0
646
657
  end
647
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
+
648
672
  def find_in_path(command)
649
673
  ENV["PATH"].split(File::PATH_SEPARATOR).each do |directory|
650
674
  path = File.join(directory, command)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruborg
4
- VERSION = "0.9.3"
4
+ VERSION = "0.9.4"
5
5
  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.9.3
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michail Pantelelis