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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -0
- data/README.md +109 -18
- data/lib/ruborg/archive_cache.rb +189 -0
- data/lib/ruborg/backup.rb +85 -92
- data/lib/ruborg/catalog.rb +36 -0
- data/lib/ruborg/cli.rb +312 -126
- data/lib/ruborg/config.rb +7 -5
- data/lib/ruborg/progress.rb +81 -0
- data/lib/ruborg/repository.rb +109 -33
- data/lib/ruborg/version.rb +1 -1
- data/lib/ruborg.rb +4 -0
- metadata +4 -1
data/lib/ruborg/backup.rb
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
module Ruborg
|
|
4
4
|
# Backup operations using Borg
|
|
5
5
|
class Backup
|
|
6
|
-
def initialize(repository, config:, retention_mode: "standard", repo_name: nil, logger: nil
|
|
6
|
+
def initialize(repository, config:, retention_mode: "standard", repo_name: nil, logger: nil,
|
|
7
|
+
skip_hash_check: false, progress: nil)
|
|
7
8
|
@repository = repository
|
|
8
9
|
@config = config
|
|
9
10
|
@retention_mode = retention_mode
|
|
10
11
|
@repo_name = repo_name
|
|
11
12
|
@logger = logger
|
|
13
|
+
@skip_hash_check = skip_hash_check
|
|
14
|
+
@progress = progress
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
def create(name: nil, remove_source: false)
|
|
@@ -26,36 +29,31 @@ module Ruborg
|
|
|
26
29
|
def create_standard_archive(name, remove_source)
|
|
27
30
|
archive_name = name || Time.now.strftime("%Y-%m-%d_%H-%M-%S")
|
|
28
31
|
|
|
29
|
-
# Show repository header in console only
|
|
30
32
|
print_repository_header
|
|
31
|
-
|
|
32
|
-
# Show progress in console
|
|
33
|
-
puts "Creating archive: #{archive_name}"
|
|
33
|
+
@progress&.spin("Creating archive: #{archive_name}")
|
|
34
34
|
|
|
35
35
|
cmd = build_create_command(archive_name)
|
|
36
|
-
|
|
37
36
|
execute_borg_command(cmd)
|
|
38
37
|
|
|
39
|
-
|
|
38
|
+
@progress&.done("Archive created: #{archive_name}")
|
|
40
39
|
@logger&.info("[#{@repo_name}] Created archive #{archive_name} with #{@config.backup_paths.size} source(s)")
|
|
41
|
-
puts "✓ Archive created successfully"
|
|
42
40
|
|
|
43
41
|
remove_source_files if remove_source
|
|
44
42
|
end
|
|
45
43
|
|
|
46
44
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
47
45
|
def create_per_file_archives(name_prefix, remove_source)
|
|
48
|
-
|
|
46
|
+
@progress&.spin("Collecting files...")
|
|
49
47
|
files_to_backup = collect_files_from_paths(@config.backup_paths, @config.exclude_patterns)
|
|
48
|
+
@progress&.stop_spin
|
|
50
49
|
|
|
51
50
|
raise BorgError, "No files found to backup" if files_to_backup.empty?
|
|
52
51
|
|
|
53
|
-
|
|
52
|
+
@progress&.spin("Loading archive catalog...")
|
|
54
53
|
existing_archives = get_existing_archive_names
|
|
54
|
+
@progress&.done("Catalog loaded — #{existing_archives.size} archive(s) known")
|
|
55
55
|
|
|
56
|
-
# Show repository header in console only
|
|
57
56
|
print_repository_header
|
|
58
|
-
|
|
59
57
|
puts "Found #{files_to_backup.size} file(s) to backup"
|
|
60
58
|
|
|
61
59
|
backed_up_count = 0
|
|
@@ -77,8 +75,8 @@ module Ruborg
|
|
|
77
75
|
# Ensure archive name doesn't exceed 255 characters (filesystem limit)
|
|
78
76
|
archive_name = name_prefix || build_archive_name(@repo_name, sanitized_filename, path_hash, file_mtime)
|
|
79
77
|
|
|
80
|
-
|
|
81
|
-
print " [#{index + 1}/#{files_to_backup.size}] Backing up: #{file_path}"
|
|
78
|
+
@progress&.bar(index + 1, files_to_backup.size, File.basename(file_path))
|
|
79
|
+
$stderr.print " [#{index + 1}/#{files_to_backup.size}] Backing up: #{file_path}" unless @progress
|
|
82
80
|
|
|
83
81
|
# Check if archive already exists AND contains this exact file
|
|
84
82
|
if existing_archives.key?(archive_name)
|
|
@@ -89,15 +87,13 @@ module Ruborg
|
|
|
89
87
|
stored_size = stored_info[:size]
|
|
90
88
|
|
|
91
89
|
if current_size == stored_size
|
|
92
|
-
# Size same -> verify content hasn't changed (paranoid mode)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if current_hash == stored_hash
|
|
97
|
-
# Content truly unchanged - file is already safely backed up
|
|
98
|
-
puts " - Archive already exists (file unchanged)"
|
|
90
|
+
# Size same -> verify content hasn't changed (paranoid mode) unless skip_hash_check is enabled
|
|
91
|
+
if @skip_hash_check
|
|
92
|
+
# Skip hash check - assume file is unchanged based on size and mtime
|
|
93
|
+
puts " - Archive already exists (skipped hash check)"
|
|
99
94
|
@logger&.info(
|
|
100
|
-
"[#{@repo_name}] Skipped #{file_path} - archive #{archive_name} already exists
|
|
95
|
+
"[#{@repo_name}] Skipped #{file_path} - archive #{archive_name} already exists " \
|
|
96
|
+
"(hash check skipped)"
|
|
101
97
|
)
|
|
102
98
|
skipped_count += 1
|
|
103
99
|
|
|
@@ -106,12 +102,29 @@ module Ruborg
|
|
|
106
102
|
|
|
107
103
|
next
|
|
108
104
|
else
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
105
|
+
current_hash = calculate_file_hash(file_path)
|
|
106
|
+
stored_hash = stored_info[:hash]
|
|
107
|
+
|
|
108
|
+
if current_hash == stored_hash
|
|
109
|
+
# Content truly unchanged - file is already safely backed up
|
|
110
|
+
puts " - Archive already exists (file unchanged)"
|
|
111
|
+
@logger&.info(
|
|
112
|
+
"[#{@repo_name}] Skipped #{file_path} - archive #{archive_name} already exists (file unchanged)"
|
|
113
|
+
)
|
|
114
|
+
skipped_count += 1
|
|
115
|
+
|
|
116
|
+
# If remove_source is enabled, delete the file (it's already safely backed up)
|
|
117
|
+
remove_single_file(file_path) if remove_source
|
|
118
|
+
|
|
119
|
+
next
|
|
120
|
+
else
|
|
121
|
+
# Size same but content changed (rare: edited + truncated/padded to same size)
|
|
122
|
+
archive_name = find_next_version_name(archive_name, existing_archives)
|
|
123
|
+
@logger&.warn(
|
|
124
|
+
"[#{@repo_name}] File content changed but size/mtime unchanged for #{file_path}, " \
|
|
125
|
+
"using #{archive_name}"
|
|
126
|
+
)
|
|
127
|
+
end
|
|
115
128
|
end
|
|
116
129
|
else
|
|
117
130
|
# Size changed but mtime same -> content changed, add version suffix
|
|
@@ -133,7 +146,7 @@ module Ruborg
|
|
|
133
146
|
cmd = build_per_file_create_command(archive_name, file_path, source_dir)
|
|
134
147
|
|
|
135
148
|
execute_borg_command(cmd)
|
|
136
|
-
puts ""
|
|
149
|
+
puts "" unless @progress
|
|
137
150
|
|
|
138
151
|
# Log successful action with details
|
|
139
152
|
@logger&.info("[#{@repo_name}] Archived #{file_path} in archive #{archive_name}")
|
|
@@ -144,11 +157,13 @@ module Ruborg
|
|
|
144
157
|
end
|
|
145
158
|
# rubocop:enable Metrics/BlockLength
|
|
146
159
|
|
|
147
|
-
if skipped_count.positive?
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
summary = if skipped_count.positive?
|
|
161
|
+
"#{backed_up_count} file(s) backed up, #{skipped_count} skipped (unchanged)"
|
|
162
|
+
else
|
|
163
|
+
"#{backed_up_count} file(s) backed up"
|
|
164
|
+
end
|
|
165
|
+
@progress&.done(summary)
|
|
166
|
+
puts "✓ Per-file backup completed: #{summary}" unless @progress
|
|
152
167
|
end
|
|
153
168
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
154
169
|
|
|
@@ -443,12 +458,11 @@ module Ruborg
|
|
|
443
458
|
puts "=" * 60
|
|
444
459
|
end
|
|
445
460
|
|
|
446
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
461
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
447
462
|
def get_existing_archive_names
|
|
448
463
|
require "json"
|
|
449
464
|
require "open3"
|
|
450
465
|
|
|
451
|
-
# First get list of archives
|
|
452
466
|
cmd = [@repository.borg_path, "list", @repository.path, "--json"]
|
|
453
467
|
env = {}
|
|
454
468
|
passphrase = @repository.instance_variable_get(:@passphrase)
|
|
@@ -459,73 +473,52 @@ module Ruborg
|
|
|
459
473
|
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
460
474
|
raise BorgError, "Failed to list archives: #{stderr}" unless status.success?
|
|
461
475
|
|
|
462
|
-
|
|
463
|
-
|
|
476
|
+
archives = JSON.parse(stdout)["archives"] || []
|
|
477
|
+
cache = ArchiveCache.new(@repository.path).fetch
|
|
464
478
|
|
|
465
|
-
|
|
466
|
-
# This is necessary because 'borg list' doesn't include comments
|
|
467
|
-
archives.each_with_object({}) do |archive, hash|
|
|
479
|
+
result = archives.each_with_object({}) do |archive, hash|
|
|
468
480
|
archive_name = archive["name"]
|
|
469
481
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
info_stdout, _, info_status = Open3.capture3(env, *info_cmd)
|
|
473
|
-
|
|
474
|
-
unless info_status.success?
|
|
475
|
-
# If we can't get info for this archive, skip it with defaults
|
|
476
|
-
hash[archive_name] = { path: "", size: 0, hash: "", source_dir: "" }
|
|
482
|
+
if (cached = cache[archive_name])
|
|
483
|
+
hash[archive_name] = cached
|
|
477
484
|
next
|
|
478
485
|
end
|
|
479
486
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
# - Old format 2: path|||hash (2 parts)
|
|
489
|
-
# - Old format 3: path|||size|||hash (3 parts)
|
|
490
|
-
# - New format: path|||size|||hash|||source_dir (4 parts)
|
|
491
|
-
if comment.include?("|||")
|
|
492
|
-
parts = comment.split("|||")
|
|
493
|
-
file_path = parts[0]
|
|
494
|
-
if parts.length >= 4
|
|
495
|
-
# New format: path|||size|||hash|||source_dir
|
|
496
|
-
file_size = parts[1].to_i
|
|
497
|
-
file_hash = parts[2] || ""
|
|
498
|
-
source_dir = parts[3] || ""
|
|
499
|
-
elsif parts.length >= 3
|
|
500
|
-
# Format 3: path|||size|||hash (no source_dir)
|
|
501
|
-
file_size = parts[1].to_i
|
|
502
|
-
file_hash = parts[2] || ""
|
|
503
|
-
source_dir = ""
|
|
504
|
-
else
|
|
505
|
-
# Old format: path|||hash (size and source_dir not available)
|
|
506
|
-
file_size = 0
|
|
507
|
-
file_hash = parts[1] || ""
|
|
508
|
-
source_dir = ""
|
|
509
|
-
end
|
|
510
|
-
else
|
|
511
|
-
# Oldest format: comment is just the path string
|
|
512
|
-
file_path = comment
|
|
513
|
-
file_size = 0
|
|
514
|
-
file_hash = ""
|
|
515
|
-
source_dir = ""
|
|
516
|
-
end
|
|
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
|
|
517
495
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
size: file_size,
|
|
521
|
-
hash: file_hash,
|
|
522
|
-
source_dir: source_dir
|
|
523
|
-
}
|
|
496
|
+
cache.store(archive_name, metadata)
|
|
497
|
+
hash[archive_name] = metadata
|
|
524
498
|
end
|
|
499
|
+
|
|
500
|
+
cache.save_if_changed
|
|
501
|
+
result
|
|
525
502
|
rescue JSON::ParserError => e
|
|
526
503
|
raise BorgError, "Failed to parse archive info: #{e.message}"
|
|
527
504
|
end
|
|
528
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
505
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
506
|
+
|
|
507
|
+
def parse_archive_comment(comment)
|
|
508
|
+
if comment.include?("|||")
|
|
509
|
+
parts = comment.split("|||")
|
|
510
|
+
file_path = parts[0]
|
|
511
|
+
if parts.length >= 4
|
|
512
|
+
{ path: file_path, size: parts[1].to_i, hash: parts[2] || "", source_dir: parts[3] || "" }
|
|
513
|
+
elsif parts.length >= 3
|
|
514
|
+
{ path: file_path, size: parts[1].to_i, hash: parts[2] || "", source_dir: "" }
|
|
515
|
+
else
|
|
516
|
+
{ path: file_path, size: 0, hash: parts[1] || "", source_dir: "" }
|
|
517
|
+
end
|
|
518
|
+
else
|
|
519
|
+
{ path: comment, size: 0, hash: "", source_dir: "" }
|
|
520
|
+
end
|
|
521
|
+
end
|
|
529
522
|
|
|
530
523
|
def find_next_version_name(base_name, existing_archives)
|
|
531
524
|
version = 2
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruborg
|
|
4
|
+
# Read-only view over the ArchiveCache for searching and reporting.
|
|
5
|
+
# Never writes back to the cache.
|
|
6
|
+
class Catalog
|
|
7
|
+
def initialize(repo_path)
|
|
8
|
+
@cache = ArchiveCache.new(repo_path).fetch
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Returns all cached entries sorted by file path.
|
|
12
|
+
def list
|
|
13
|
+
@cache.entries.sort_by { |e| e[:path].to_s }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns entries whose :path matches +pattern+ (a Regexp or regex string).
|
|
17
|
+
# Raises CatalogError on invalid regex.
|
|
18
|
+
def search(pattern)
|
|
19
|
+
regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern)
|
|
20
|
+
list.select { |e| regex.match?(e[:path].to_s) }
|
|
21
|
+
rescue RegexpError => e
|
|
22
|
+
raise CatalogError, "Invalid regex pattern: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns a summary hash with aggregate statistics.
|
|
26
|
+
def stats
|
|
27
|
+
all = list
|
|
28
|
+
{
|
|
29
|
+
total_archives: all.size,
|
|
30
|
+
unique_paths: all.map { |e| e[:path] }.uniq.size,
|
|
31
|
+
total_size: all.sum { |e| e[:size].to_i },
|
|
32
|
+
source_dirs: all.map { |e| e[:source_dir] }.uniq.reject(&:empty?).size
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|