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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +18 -2
- data/lib/ruborg/backup.rb +44 -25
- data/lib/ruborg/cli.rb +9 -3
- data/lib/ruborg/progress.rb +14 -1
- data/lib/ruborg/repository.rb +24 -0
- data/lib/ruborg/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b10d4c2ab697830a9ac1b3e4ce1e13de22728577b2c5f7326465dd9ac877d688
|
|
4
|
+
data.tar.gz: 488993366f00681563d850468c28e50bbdeffae07aa2da7e1925451d813ab135
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
492
|
+
# Check both repository and cache locks (exits 0 = no lock, 1 = locked)
|
|
484
493
|
ruborg lock --repository documents
|
|
485
494
|
|
|
486
|
-
# Break
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
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
|
-
|
|
513
|
+
result[archive_name] = cached
|
|
484
514
|
next
|
|
485
515
|
end
|
|
486
516
|
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/ruborg/progress.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/ruborg/repository.rb
CHANGED
|
@@ -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)
|
data/lib/ruborg/version.rb
CHANGED