ruborg 0.8.0 → 0.9.0

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.
@@ -114,7 +114,7 @@ module Ruborg
114
114
  # For example: /var/folders/foo -> var/folders/foo
115
115
  # Try both the original path and the path with leading slash removed
116
116
  normalized_path = file_path.start_with?("/") ? file_path[1..] : file_path
117
- file_metadata = files.find { |f| f["path"] == file_path || f["path"] == normalized_path }
117
+ file_metadata = files.find { |f| [file_path, normalized_path].include?(f["path"]) }
118
118
  raise BorgError, "File '#{file_path}' not found in archive" unless file_metadata
119
119
 
120
120
  file_metadata
@@ -166,8 +166,8 @@ module Ruborg
166
166
 
167
167
  unless keep_files_modified_within
168
168
  # Fall back to standard pruning if no file metadata retention specified
169
- @logger&.info("No file metadata retention specified, using standard pruning")
170
- prune_standard_archives(retention_policy)
169
+ @logger&.info("No file metadata retention specified, using standard pruning per directory")
170
+ prune_per_directory_standard(retention_policy)
171
171
  return
172
172
  end
173
173
 
@@ -176,35 +176,50 @@ module Ruborg
176
176
  # Parse time duration (e.g., "30d" -> 30 days)
177
177
  cutoff_time = Time.now - parse_time_duration(keep_files_modified_within)
178
178
 
179
- # Get all archives with metadata
180
- archives = list_archives_with_metadata
181
- @logger&.info("Found #{archives.size} archive(s) to evaluate for pruning")
179
+ # Get all archives with metadata including source directory
180
+ archives_by_source = get_archives_grouped_by_source_dir
181
+ @logger&.info("Found #{archives_by_source.values.sum(&:size)} archive(s) in #{archives_by_source.size} source director(ies)")
182
182
 
183
- archives_to_delete = []
183
+ total_deleted = 0
184
184
 
185
- archives.each do |archive|
186
- # Get file metadata from archive
187
- file_mtime = get_file_mtime_from_archive(archive[:name])
185
+ # Process each source directory separately
186
+ archives_by_source.each do |source_dir, archives|
187
+ source_desc = source_dir.empty? ? "legacy archives (no source dir)" : source_dir
188
+ @logger&.info("Processing source directory: #{source_desc} (#{archives.size} archives)")
189
+
190
+ archives_to_delete = []
188
191
 
189
- # Delete archive if file was modified before cutoff
190
- if file_mtime && file_mtime < cutoff_time
191
- archives_to_delete << archive[:name]
192
- @logger&.debug("Archive #{archive[:name]} marked for deletion (file mtime: #{file_mtime})")
192
+ archives.each do |archive|
193
+ # Get file metadata from archive
194
+ file_mtime = get_file_mtime_from_archive(archive[:name])
195
+
196
+ # Delete archive if file was modified before cutoff
197
+ if file_mtime && file_mtime < cutoff_time
198
+ archives_to_delete << archive[:name]
199
+ @logger&.debug("Archive #{archive[:name]} marked for deletion (file mtime: #{file_mtime})")
200
+ end
193
201
  end
194
- end
195
202
 
196
- return if archives_to_delete.empty?
203
+ next if archives_to_delete.empty?
197
204
 
198
- @logger&.info("Deleting #{archives_to_delete.size} archive(s)")
205
+ @logger&.info("Deleting #{archives_to_delete.size} archive(s) from #{source_desc}")
199
206
 
200
- # Delete archives
201
- archives_to_delete.each do |archive_name|
202
- @logger&.debug("Deleting archive: #{archive_name}")
203
- delete_archive(archive_name)
207
+ # Delete archives
208
+ archives_to_delete.each do |archive_name|
209
+ @logger&.debug("Deleting archive: #{archive_name}")
210
+ delete_archive(archive_name)
211
+ end
212
+
213
+ total_deleted += archives_to_delete.size
204
214
  end
205
215
 
206
- @logger&.info("Pruned #{archives_to_delete.size} archive(s) based on file modification time")
207
- puts "Pruned #{archives_to_delete.size} archive(s) based on file modification time"
216
+ if total_deleted.zero?
217
+ @logger&.info("No archives to prune")
218
+ puts "No archives to prune"
219
+ else
220
+ @logger&.info("Pruned #{total_deleted} archive(s) total across all source directories")
221
+ puts "Pruned #{total_deleted} archive(s) based on file modification time"
222
+ end
208
223
  end
209
224
 
210
225
  def list_archives_with_metadata
@@ -263,6 +278,142 @@ module Ruborg
263
278
  nil # Failed to parse, skip this archive
264
279
  end
265
280
 
281
+ def get_archives_grouped_by_source_dir
282
+ require "json"
283
+ require "time"
284
+ require "open3"
285
+
286
+ # Get list of all archives
287
+ cmd = [@borg_path, "list", @path, "--json"]
288
+ env = build_borg_env
289
+
290
+ stdout, stderr, status = Open3.capture3(env, *cmd)
291
+ raise BorgError, "Failed to list archives: #{stderr}" unless status.success?
292
+
293
+ json_data = JSON.parse(stdout)
294
+ archives = json_data["archives"] || []
295
+
296
+ # Group archives by source directory from metadata
297
+ archives_by_source = Hash.new { |h, k| h[k] = [] }
298
+
299
+ archives.each do |archive|
300
+ 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] || "") : ""
323
+ else
324
+ ""
325
+ end
326
+
327
+ archives_by_source[source_dir] << {
328
+ name: archive_name,
329
+ time: Time.parse(archive["time"])
330
+ }
331
+ end
332
+
333
+ archives_by_source
334
+ rescue JSON::ParserError => e
335
+ raise BorgError, "Failed to parse archive metadata: #{e.message}"
336
+ end
337
+
338
+ def prune_per_directory_standard(retention_policy)
339
+ # Apply standard retention policies (keep_daily, etc.) per source directory
340
+ archives_by_source = get_archives_grouped_by_source_dir
341
+ @logger&.info("Applying standard retention per directory: #{archives_by_source.size} director(ies)")
342
+
343
+ total_pruned = 0
344
+
345
+ archives_by_source.each do |source_dir, archives|
346
+ source_desc = source_dir.empty? ? "legacy archives (no source dir)" : source_dir
347
+ @logger&.info("Processing source directory: #{source_desc} (#{archives.size} archives)")
348
+
349
+ # Create a temporary prefix to filter this directory's archives
350
+ # Since we can't directly use borg prune with filtering, we need to delete individually
351
+ archives_to_keep = apply_retention_policy(archives, retention_policy)
352
+ archives_to_delete = archives.map { |a| a[:name] } - archives_to_keep.map { |a| a[:name] }
353
+
354
+ next if archives_to_delete.empty?
355
+
356
+ @logger&.info("Pruning #{archives_to_delete.size} archive(s) from #{source_desc}")
357
+
358
+ archives_to_delete.each do |archive_name|
359
+ @logger&.debug("Deleting archive: #{archive_name}")
360
+ delete_archive(archive_name)
361
+ end
362
+
363
+ total_pruned += archives_to_delete.size
364
+ end
365
+
366
+ if total_pruned.zero?
367
+ @logger&.info("No archives to prune")
368
+ puts "No archives to prune"
369
+ else
370
+ @logger&.info("Pruned #{total_pruned} archive(s) total across all source directories")
371
+ puts "Pruned #{total_pruned} archive(s) across all source directories"
372
+ end
373
+ end
374
+
375
+ def apply_retention_policy(archives, policy)
376
+ # Sort archives by time (newest first)
377
+ sorted = archives.sort_by { |a| a[:time] }.reverse
378
+ to_keep = []
379
+
380
+ # Apply keep_last first (if specified)
381
+ to_keep += sorted.take(policy["keep_last"]) if policy["keep_last"]
382
+
383
+ # Apply time-based retention (keep_within)
384
+ if policy["keep_within"]
385
+ cutoff = Time.now - parse_time_duration(policy["keep_within"])
386
+ to_keep += sorted.select { |a| a[:time] >= cutoff }
387
+ end
388
+
389
+ # Apply count-based retention (keep_daily, keep_weekly, etc.)
390
+ # Group archives by time period and keep the newest from each period
391
+ %w[hourly daily weekly monthly yearly].each do |period|
392
+ keep_count = policy["keep_#{period}"]
393
+ next unless keep_count
394
+
395
+ case period
396
+ when "hourly"
397
+ grouped = sorted.group_by { |a| a[:time].strftime("%Y-%m-%d-%H") }
398
+ when "daily"
399
+ grouped = sorted.group_by { |a| a[:time].strftime("%Y-%m-%d") }
400
+ when "weekly"
401
+ grouped = sorted.group_by { |a| a[:time].strftime("%Y-W%W") }
402
+ when "monthly"
403
+ grouped = sorted.group_by { |a| a[:time].strftime("%Y-%m") }
404
+ when "yearly"
405
+ grouped = sorted.group_by { |a| a[:time].strftime("%Y") }
406
+ end
407
+
408
+ # Keep the newest archive from each of the most recent N periods
409
+ grouped.keys.sort.reverse.take(keep_count.to_i).each do |key|
410
+ to_keep << grouped[key].first
411
+ end
412
+ end
413
+
414
+ to_keep.uniq { |a| a[:name] }
415
+ end
416
+
266
417
  def delete_archive(archive_name)
267
418
  cmd = [@borg_path, "delete", "#{@path}::#{archive_name}"]
268
419
  execute_borg_command(cmd)
@@ -326,6 +477,21 @@ module Ruborg
326
477
  match[1]
327
478
  end
328
479
 
480
+ # Get Borg path (full path to executable)
481
+ def self.borg_path(borg_command = "borg")
482
+ # If it's an absolute or relative path, expand it
483
+ return File.expand_path(borg_command) if borg_command.include?("/")
484
+
485
+ # Otherwise, search in PATH
486
+ ENV["PATH"].split(File::PATH_SEPARATOR).each do |directory|
487
+ path = File.join(directory, borg_command)
488
+ return path if File.executable?(path)
489
+ end
490
+
491
+ # Not found in PATH, return the command as-is
492
+ borg_command
493
+ end
494
+
329
495
  # Execute borg version command (extracted for testing)
330
496
  def self.execute_version_command(borg_path = "borg")
331
497
  require "open3"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruborg
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/ruborg.gemspec ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/ruborg/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "ruborg"
7
+ spec.version = Ruborg::VERSION
8
+ spec.authors = ["Michail Pantelelis"]
9
+ spec.email = ["mpantel@aegean.gr"]
10
+
11
+ spec.summary = "A friendly Ruby frontend for Borg backup"
12
+ spec.description = "Ruborg provides a user-friendly interface to Borg backup. " \
13
+ "It reads YAML configuration files and orchestrates backup operations, " \
14
+ "supporting repository creation, backup management, and Passbolt integration."
15
+ spec.homepage = "https://github.com/mpantel/ruborg"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.2.0"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "#{spec.homepage}.git"
21
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
22
+ spec.metadata["rubygems_mfa_required"] = "true"
23
+
24
+ # Specify which files should be added to the gem when it is released.
25
+ spec.files = Dir.chdir(__dir__) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (File.expand_path(f) == __FILE__) ||
28
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
29
+ end
30
+ end
31
+ spec.bindir = "exe"
32
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ["lib"]
34
+
35
+ # Dependencies
36
+ spec.add_dependency "psych", "~> 5.0"
37
+ spec.add_dependency "thor", "~> 1.3"
38
+
39
+ # Development dependencies
40
+ spec.add_development_dependency "bundler", "~> 2.0"
41
+ spec.add_development_dependency "bundler-audit", "~> 0.9"
42
+ spec.add_development_dependency "rake", "~> 13.0"
43
+ spec.add_development_dependency "rspec", "~> 3.0"
44
+ spec.add_development_dependency "rubocop", "~> 1.0"
45
+ spec.add_development_dependency "rubocop-rspec", "~> 3.0"
46
+ end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruborg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michail Pantelelis
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2025-10-14 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: psych
@@ -137,6 +138,7 @@ files:
137
138
  - CHANGELOG.md
138
139
  - CLAUDE.md
139
140
  - LICENSE
141
+ - PER_DIRECTORY_RETENTION.md
140
142
  - README.md
141
143
  - Rakefile
142
144
  - SECURITY.md
@@ -149,6 +151,7 @@ files:
149
151
  - lib/ruborg/passbolt.rb
150
152
  - lib/ruborg/repository.rb
151
153
  - lib/ruborg/version.rb
154
+ - ruborg.gemspec
152
155
  - ruborg.yml.example
153
156
  homepage: https://github.com/mpantel/ruborg
154
157
  licenses:
@@ -158,6 +161,7 @@ metadata:
158
161
  source_code_uri: https://github.com/mpantel/ruborg.git
159
162
  changelog_uri: https://github.com/mpantel/ruborg/blob/main/CHANGELOG.md
160
163
  rubygems_mfa_required: 'true'
164
+ post_install_message:
161
165
  rdoc_options: []
162
166
  require_paths:
163
167
  - lib
@@ -172,7 +176,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
172
176
  - !ruby/object:Gem::Version
173
177
  version: '0'
174
178
  requirements: []
175
- rubygems_version: 3.7.1
179
+ rubygems_version: 3.5.22
180
+ signing_key:
176
181
  specification_version: 4
177
182
  summary: A friendly Ruby frontend for Borg backup
178
183
  test_files: []