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.
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
- # Log successful action
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
- # Collect all files from backup paths
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
- # Get list of existing archives for duplicate detection
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
- # Show progress in console
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
- current_hash = calculate_file_hash(file_path)
94
- stored_hash = stored_info[:hash]
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 (file unchanged)"
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
- # Size same but content changed (rare: edited + truncated/padded to same size)
110
- archive_name = find_next_version_name(archive_name, existing_archives)
111
- @logger&.warn(
112
- "[#{@repo_name}] File content changed but size/mtime unchanged for #{file_path}, " \
113
- "using #{archive_name}"
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
- puts "✓ Per-file backup completed: #{backed_up_count} file(s) backed up, #{skipped_count} skipped (unchanged)"
149
- else
150
- puts "✓ Per-file backup completed: #{backed_up_count} file(s) backed up"
151
- end
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, Metrics/PerceivedComplexity
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
- json_data = JSON.parse(stdout)
463
- archives = json_data["archives"] || []
476
+ archives = JSON.parse(stdout)["archives"] || []
477
+ cache = ArchiveCache.new(@repository.path).fetch
464
478
 
465
- # Build hash by querying each archive individually for comment
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
- # Query this specific archive to get the comment
471
- info_cmd = [@repository.borg_path, "info", "#{@repository.path}::#{archive_name}", "--json"]
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
- info_data = JSON.parse(info_stdout)
481
- archive_info = info_data["archives"]&.first || {}
482
- comment = archive_info["comment"] || ""
483
-
484
- # Parse comment based on format
485
- # The comment field stores metadata as: path|||size|||hash|||source_dir (using ||| as delimiter)
486
- # For backward compatibility, handle old formats:
487
- # - Old format 1: plain path (no |||)
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
- hash[archive_name] = {
519
- path: file_path,
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, Metrics/PerceivedComplexity
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