ruborg 0.7.6 → 0.8.1
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 +68 -0
 - data/PER_DIRECTORY_RETENTION.md +297 -0
 - data/README.md +3 -20
 - data/SECURITY.md +1 -9
 - data/lib/ruborg/backup.rb +29 -79
 - data/lib/ruborg/repository.rb +174 -23
 - data/lib/ruborg/version.rb +1 -1
 - data/ruborg.gemspec +46 -0
 - metadata +3 -1
 
    
        checksums.yaml
    CHANGED
    
    | 
         @@ -1,7 +1,7 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            ---
         
     | 
| 
       2 
2 
     | 
    
         
             
            SHA256:
         
     | 
| 
       3 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       4 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 3 
     | 
    
         
            +
              metadata.gz: 74bbfee5ede7b87c5a99ddec057c90430ee66a5667e6a933455baf4478f569d2
         
     | 
| 
      
 4 
     | 
    
         
            +
              data.tar.gz: f7ddb9aa319573d39888fad0933de0ce99fcc08069eae459550605eba9ff1082
         
     | 
| 
       5 
5 
     | 
    
         
             
            SHA512:
         
     | 
| 
       6 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       7 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 6 
     | 
    
         
            +
              metadata.gz: 285d04844f53fc87e5af8b28d24fc9dfe410e1610e2c79845ea0b1a5ea8a7d60ed76c5df2f667068b0c2b55269870d9af2c30f64ed9f8efc63312d1f99acd60a
         
     | 
| 
      
 7 
     | 
    
         
            +
              data.tar.gz: e72be5e589955c1e3832a5f38236d485d61db23a8f60ece3a147a6ecf21eb1b66fd9e1eddbc77c4720c4dc69f675491eb4c7eb5dc22b93b2a1856757af4e7a96
         
     | 
    
        data/CHANGELOG.md
    CHANGED
    
    | 
         @@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 
     | 
|
| 
       7 
7 
     | 
    
         | 
| 
       8 
8 
     | 
    
         
             
            ## [Unreleased]
         
     | 
| 
       9 
9 
     | 
    
         | 
| 
      
 10 
     | 
    
         
            +
            ## [0.8.1] - 2025-10-09
         
     | 
| 
      
 11 
     | 
    
         
            +
             
     | 
| 
      
 12 
     | 
    
         
            +
            ### Added
         
     | 
| 
      
 13 
     | 
    
         
            +
            - **Per-Directory Retention**: Retention policies now apply independently to each source directory in per-file backup mode
         
     | 
| 
      
 14 
     | 
    
         
            +
              - Each `paths` entry in repository sources gets its own retention quota
         
     | 
| 
      
 15 
     | 
    
         
            +
              - Prevents one active directory from dominating retention across all sources
         
     | 
| 
      
 16 
     | 
    
         
            +
              - Example: `keep_daily: 14` keeps 14 archives per source directory, not 14 total
         
     | 
| 
      
 17 
     | 
    
         
            +
              - Works with both `keep_files_modified_within` and standard retention policies (`keep_daily`, `keep_weekly`, etc.)
         
     | 
| 
      
 18 
     | 
    
         
            +
              - Legacy archives (without source_dir metadata) grouped separately for backward compatibility
         
     | 
| 
      
 19 
     | 
    
         
            +
            - **Enhanced Archive Metadata**: Archive comments now include source directory
         
     | 
| 
      
 20 
     | 
    
         
            +
              - New format: `path|||size|||hash|||source_dir` (4-field format)
         
     | 
| 
      
 21 
     | 
    
         
            +
              - Backward compatible with all previous formats (3-field, 2-field, plain path)
         
     | 
| 
      
 22 
     | 
    
         
            +
              - Enables accurate per-directory grouping and retention
         
     | 
| 
      
 23 
     | 
    
         
            +
            - **Comprehensive Test Suite**: Added 6 new per-directory retention tests (27 total examples, 0 failures)
         
     | 
| 
      
 24 
     | 
    
         
            +
              - Independent retention per source directory
         
     | 
| 
      
 25 
     | 
    
         
            +
              - Separate retention quotas with `keep_daily`
         
     | 
| 
      
 26 
     | 
    
         
            +
              - Archive metadata validation
         
     | 
| 
      
 27 
     | 
    
         
            +
              - Legacy archive grouping
         
     | 
| 
      
 28 
     | 
    
         
            +
              - Mixed format pruning
         
     | 
| 
      
 29 
     | 
    
         
            +
              - Per-directory `keep_files_modified_within`
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
            ### Changed
         
     | 
| 
      
 32 
     | 
    
         
            +
            - **File Collection Tracking**: Files now tracked with both path and originating source directory
         
     | 
| 
      
 33 
     | 
    
         
            +
              - Modified `collect_files_from_paths` to return `{path:, source_dir:}` hash format
         
     | 
| 
      
 34 
     | 
    
         
            +
              - Source directory captured from expanded backup paths
         
     | 
| 
      
 35 
     | 
    
         
            +
              - Used for per-directory retention grouping during pruning
         
     | 
| 
      
 36 
     | 
    
         
            +
            - **Archive Grouping**: Per-file archives grouped by source directory during pruning
         
     | 
| 
      
 37 
     | 
    
         
            +
              - New method: `get_archives_grouped_by_source_dir` (lib/ruborg/repository.rb:281-336)
         
     | 
| 
      
 38 
     | 
    
         
            +
              - Queries archive metadata to extract source directory
         
     | 
| 
      
 39 
     | 
    
         
            +
              - Returns hash: `{"/path/to/source" => [archives]}`
         
     | 
| 
      
 40 
     | 
    
         
            +
              - Handles legacy archives gracefully (empty source_dir)
         
     | 
| 
      
 41 
     | 
    
         
            +
            - **Pruning Logic**: Per-file pruning now processes each directory independently
         
     | 
| 
      
 42 
     | 
    
         
            +
              - Method: `prune_per_file_archives` (lib/ruborg/repository.rb:163-223)
         
     | 
| 
      
 43 
     | 
    
         
            +
              - Applies retention policy separately to each source directory group
         
     | 
| 
      
 44 
     | 
    
         
            +
              - Logs per-directory pruning statistics
         
     | 
| 
      
 45 
     | 
    
         
            +
              - Falls back to standard pruning when `keep_files_modified_within` not specified
         
     | 
| 
      
 46 
     | 
    
         
            +
             
     | 
| 
      
 47 
     | 
    
         
            +
            ### Technical Details
         
     | 
| 
      
 48 
     | 
    
         
            +
            - Per-directory retention queries archive metadata once per pruning operation
         
     | 
| 
      
 49 
     | 
    
         
            +
            - One `borg info` call per archive to read metadata (noted in documentation as potential optimization)
         
     | 
| 
      
 50 
     | 
    
         
            +
            - Backward compatibility: Archives without `source_dir` default to empty string and group as "legacy"
         
     | 
| 
      
 51 
     | 
    
         
            +
            - No migration required: Old archives naturally age out, new archives have proper metadata
         
     | 
| 
      
 52 
     | 
    
         
            +
            - Implementation documented in `PER_DIRECTORY_RETENTION.md`
         
     | 
| 
      
 53 
     | 
    
         
            +
             
     | 
| 
      
 54 
     | 
    
         
            +
            ### Security
         
     | 
| 
      
 55 
     | 
    
         
            +
            - **Security Audit: PASS** ✓
         
     | 
| 
      
 56 
     | 
    
         
            +
              - No HIGH or MEDIUM severity issues identified
         
     | 
| 
      
 57 
     | 
    
         
            +
              - 1 LOW severity information disclosure (minor log message, acceptable)
         
     | 
| 
      
 58 
     | 
    
         
            +
              - All command execution uses safe array syntax (`Open3.capture3`)
         
     | 
| 
      
 59 
     | 
    
         
            +
              - Path validation maintained for all operations
         
     | 
| 
      
 60 
     | 
    
         
            +
              - Safe JSON parsing with error handling
         
     | 
| 
      
 61 
     | 
    
         
            +
              - No code evaluation or unsafe deserialization
         
     | 
| 
      
 62 
     | 
    
         
            +
              - Backward-compatible metadata parsing with safe defaults
         
     | 
| 
      
 63 
     | 
    
         
            +
              - Sensitive data (passphrases) kept in environment variables only
         
     | 
| 
      
 64 
     | 
    
         
            +
             
     | 
| 
      
 65 
     | 
    
         
            +
            ## [0.8.0] - 2025-10-09
         
     | 
| 
      
 66 
     | 
    
         
            +
             
     | 
| 
      
 67 
     | 
    
         
            +
            ### Removed
         
     | 
| 
      
 68 
     | 
    
         
            +
            - **chattr/lsattr Functionality**: Completely removed Linux immutable attribute handling
         
     | 
| 
      
 69 
     | 
    
         
            +
              - The feature caused issues with network filesystems (CIFS/SMB, NFS) that don't support chattr
         
     | 
| 
      
 70 
     | 
    
         
            +
              - Users with truly immutable files should remove the attribute manually before using `--remove-source`
         
     | 
| 
      
 71 
     | 
    
         
            +
              - Simplifies code and eliminates filesystem compatibility issues
         
     | 
| 
      
 72 
     | 
    
         
            +
              - `--remove-source` now relies on standard file permissions only
         
     | 
| 
      
 73 
     | 
    
         
            +
             
     | 
| 
      
 74 
     | 
    
         
            +
            ### Changed
         
     | 
| 
      
 75 
     | 
    
         
            +
            - File deletion now uses standard Ruby FileUtils methods without chattr checks
         
     | 
| 
      
 76 
     | 
    
         
            +
            - Improved compatibility with all filesystem types (local, network, cloud)
         
     | 
| 
      
 77 
     | 
    
         
            +
             
     | 
| 
       10 
78 
     | 
    
         
             
            ## [0.7.6] - 2025-10-09
         
     | 
| 
       11 
79 
     | 
    
         | 
| 
       12 
80 
     | 
    
         
             
            ### Fixed
         
     | 
| 
         @@ -0,0 +1,297 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # Per-Directory Retention Implementation
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            ## Overview
         
     | 
| 
      
 4 
     | 
    
         
            +
             
     | 
| 
      
 5 
     | 
    
         
            +
            This document describes the per-directory retention feature implemented for per-file backup mode in Ruborg. Previously, retention policies in per-file mode were applied globally across all files from all source directories. Now, retention is applied separately for each source directory.
         
     | 
| 
      
 6 
     | 
    
         
            +
             
     | 
| 
      
 7 
     | 
    
         
            +
            ## Changes Made
         
     | 
| 
      
 8 
     | 
    
         
            +
             
     | 
| 
      
 9 
     | 
    
         
            +
            ### 1. Archive Metadata Enhancement
         
     | 
| 
      
 10 
     | 
    
         
            +
             
     | 
| 
      
 11 
     | 
    
         
            +
            **File:** `lib/ruborg/backup.rb`
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
      
 13 
     | 
    
         
            +
            **Location:** Lines 256-271 (`build_per_file_create_command`)
         
     | 
| 
      
 14 
     | 
    
         
            +
             
     | 
| 
      
 15 
     | 
    
         
            +
            Added `source_dir` field to archive metadata:
         
     | 
| 
      
 16 
     | 
    
         
            +
            - **Old format:** `path|||size|||hash`
         
     | 
| 
      
 17 
     | 
    
         
            +
            - **New format:** `path|||size|||hash|||source_dir`
         
     | 
| 
      
 18 
     | 
    
         
            +
             
     | 
| 
      
 19 
     | 
    
         
            +
            The source directory is stored in the Borg archive comment field and tracks which backup path each file originated from.
         
     | 
| 
      
 20 
     | 
    
         
            +
             
     | 
| 
      
 21 
     | 
    
         
            +
            ### 2. File Collection Tracking
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
            **File:** `lib/ruborg/backup.rb`
         
     | 
| 
      
 24 
     | 
    
         
            +
             
     | 
| 
      
 25 
     | 
    
         
            +
            **Location:** Lines 155-177 (`collect_files_from_paths`)
         
     | 
| 
      
 26 
     | 
    
         
            +
             
     | 
| 
      
 27 
     | 
    
         
            +
            Modified to return hash with file path and source directory:
         
     | 
| 
      
 28 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 29 
     | 
    
         
            +
            { path: "/var/log/syslog", source_dir: "/var/log" }
         
     | 
| 
      
 30 
     | 
    
         
            +
            ```
         
     | 
| 
      
 31 
     | 
    
         
            +
             
     | 
| 
      
 32 
     | 
    
         
            +
            Each file now knows its originating backup directory.
         
     | 
| 
      
 33 
     | 
    
         
            +
             
     | 
| 
      
 34 
     | 
    
         
            +
            ### 3. Per-Directory Pruning Logic
         
     | 
| 
      
 35 
     | 
    
         
            +
             
     | 
| 
      
 36 
     | 
    
         
            +
            **File:** `lib/ruborg/repository.rb`
         
     | 
| 
      
 37 
     | 
    
         
            +
             
     | 
| 
      
 38 
     | 
    
         
            +
            **New/Modified Methods:**
         
     | 
| 
      
 39 
     | 
    
         
            +
             
     | 
| 
      
 40 
     | 
    
         
            +
            #### `prune_per_file_archives` (Lines 163-223)
         
     | 
| 
      
 41 
     | 
    
         
            +
            - Groups archives by source directory
         
     | 
| 
      
 42 
     | 
    
         
            +
            - Applies retention policy separately to each directory
         
     | 
| 
      
 43 
     | 
    
         
            +
            - Logs per-directory pruning activity
         
     | 
| 
      
 44 
     | 
    
         
            +
             
     | 
| 
      
 45 
     | 
    
         
            +
            #### `get_archives_grouped_by_source_dir` (Lines 281-336)
         
     | 
| 
      
 46 
     | 
    
         
            +
            - Queries all archives and extracts source_dir from metadata
         
     | 
| 
      
 47 
     | 
    
         
            +
            - Returns hash: `{ "/var/log" => [archive1, archive2], "/home/user" => [archive3] }`
         
     | 
| 
      
 48 
     | 
    
         
            +
            - Handles legacy archives (empty source_dir) as separate group
         
     | 
| 
      
 49 
     | 
    
         
            +
             
     | 
| 
      
 50 
     | 
    
         
            +
            #### `prune_per_directory_standard` (Lines 338-373)
         
     | 
| 
      
 51 
     | 
    
         
            +
            - Applies standard retention policies (keep_daily, keep_weekly, etc.) per directory
         
     | 
| 
      
 52 
     | 
    
         
            +
            - Used when `keep_files_modified_within` is not specified
         
     | 
| 
      
 53 
     | 
    
         
            +
             
     | 
| 
      
 54 
     | 
    
         
            +
            #### `apply_retention_policy` (Lines 375-417)
         
     | 
| 
      
 55 
     | 
    
         
            +
            - Implements retention logic for a single directory's archives
         
     | 
| 
      
 56 
     | 
    
         
            +
            - Supports keep_last, keep_within, keep_daily, keep_weekly, keep_monthly, keep_yearly
         
     | 
| 
      
 57 
     | 
    
         
            +
             
     | 
| 
      
 58 
     | 
    
         
            +
            ### 4. Backward Compatibility
         
     | 
| 
      
 59 
     | 
    
         
            +
             
     | 
| 
      
 60 
     | 
    
         
            +
            **File:** `lib/ruborg/backup.rb`
         
     | 
| 
      
 61 
     | 
    
         
            +
             
     | 
| 
      
 62 
     | 
    
         
            +
            **Location:** Lines 486-518 (`get_existing_archive_names`)
         
     | 
| 
      
 63 
     | 
    
         
            +
             
     | 
| 
      
 64 
     | 
    
         
            +
            Enhanced metadata parsing to support multiple formats:
         
     | 
| 
      
 65 
     | 
    
         
            +
            - **Format 1 (oldest):** Plain path string (no delimiters)
         
     | 
| 
      
 66 
     | 
    
         
            +
            - **Format 2:** `path|||hash`
         
     | 
| 
      
 67 
     | 
    
         
            +
            - **Format 3:** `path|||size|||hash`
         
     | 
| 
      
 68 
     | 
    
         
            +
            - **Format 4 (new):** `path|||size|||hash|||source_dir`
         
     | 
| 
      
 69 
     | 
    
         
            +
             
     | 
| 
      
 70 
     | 
    
         
            +
            Archives without source_dir default to `source_dir: ""` and are grouped together as "legacy archives".
         
     | 
| 
      
 71 
     | 
    
         
            +
             
     | 
| 
      
 72 
     | 
    
         
            +
            ## How It Works
         
     | 
| 
      
 73 
     | 
    
         
            +
             
     | 
| 
      
 74 
     | 
    
         
            +
            ### With `keep_files_modified_within`
         
     | 
| 
      
 75 
     | 
    
         
            +
             
     | 
| 
      
 76 
     | 
    
         
            +
            **Configuration:**
         
     | 
| 
      
 77 
     | 
    
         
            +
            ```yaml
         
     | 
| 
      
 78 
     | 
    
         
            +
            retention:
         
     | 
| 
      
 79 
     | 
    
         
            +
              keep_files_modified_within: "30d"
         
     | 
| 
      
 80 
     | 
    
         
            +
            ```
         
     | 
| 
      
 81 
     | 
    
         
            +
             
     | 
| 
      
 82 
     | 
    
         
            +
            **Behavior:**
         
     | 
| 
      
 83 
     | 
    
         
            +
            - Files from `/var/log` modified in last 30 days are kept
         
     | 
| 
      
 84 
     | 
    
         
            +
            - Files from `/home/user/docs` modified in last 30 days are kept
         
     | 
| 
      
 85 
     | 
    
         
            +
            - **Each directory evaluated independently**
         
     | 
| 
      
 86 
     | 
    
         
            +
             
     | 
| 
      
 87 
     | 
    
         
            +
            ### With Standard Retention Policies
         
     | 
| 
      
 88 
     | 
    
         
            +
             
     | 
| 
      
 89 
     | 
    
         
            +
            **Configuration:**
         
     | 
| 
      
 90 
     | 
    
         
            +
            ```yaml
         
     | 
| 
      
 91 
     | 
    
         
            +
            retention:
         
     | 
| 
      
 92 
     | 
    
         
            +
              keep_daily: 14
         
     | 
| 
      
 93 
     | 
    
         
            +
              keep_weekly: 4
         
     | 
| 
      
 94 
     | 
    
         
            +
              keep_monthly: 6
         
     | 
| 
      
 95 
     | 
    
         
            +
            ```
         
     | 
| 
      
 96 
     | 
    
         
            +
             
     | 
| 
      
 97 
     | 
    
         
            +
            **Old Behavior (before this change):**
         
     | 
| 
      
 98 
     | 
    
         
            +
            - 14 archives total across ALL directories
         
     | 
| 
      
 99 
     | 
    
         
            +
            - If one directory is more active, it could dominate the retention
         
     | 
| 
      
 100 
     | 
    
         
            +
             
     | 
| 
      
 101 
     | 
    
         
            +
            **New Behavior:**
         
     | 
| 
      
 102 
     | 
    
         
            +
            - 14 daily archives from `/var/log`
         
     | 
| 
      
 103 
     | 
    
         
            +
            - PLUS 14 daily archives from `/home/user/docs`
         
     | 
| 
      
 104 
     | 
    
         
            +
            - Each directory gets its full retention quota
         
     | 
| 
      
 105 
     | 
    
         
            +
             
     | 
| 
      
 106 
     | 
    
         
            +
            ## Example Configuration
         
     | 
| 
      
 107 
     | 
    
         
            +
             
     | 
| 
      
 108 
     | 
    
         
            +
            ```yaml
         
     | 
| 
      
 109 
     | 
    
         
            +
            repositories:
         
     | 
| 
      
 110 
     | 
    
         
            +
              - name: databases
         
     | 
| 
      
 111 
     | 
    
         
            +
                path: /mnt/backup/borg-databases
         
     | 
| 
      
 112 
     | 
    
         
            +
                retention_mode: per_file
         
     | 
| 
      
 113 
     | 
    
         
            +
                retention:
         
     | 
| 
      
 114 
     | 
    
         
            +
                  # Keep files modified within last 30 days from EACH directory
         
     | 
| 
      
 115 
     | 
    
         
            +
                  keep_files_modified_within: "30d"
         
     | 
| 
      
 116 
     | 
    
         
            +
                  # OR use standard retention (14 daily archives per directory)
         
     | 
| 
      
 117 
     | 
    
         
            +
                  keep_daily: 14
         
     | 
| 
      
 118 
     | 
    
         
            +
                sources:
         
     | 
| 
      
 119 
     | 
    
         
            +
                  - name: mysql-dumps
         
     | 
| 
      
 120 
     | 
    
         
            +
                    paths:
         
     | 
| 
      
 121 
     | 
    
         
            +
                      - /var/backups/mysql      # Gets its own retention quota
         
     | 
| 
      
 122 
     | 
    
         
            +
                  - name: postgres-dumps
         
     | 
| 
      
 123 
     | 
    
         
            +
                    paths:
         
     | 
| 
      
 124 
     | 
    
         
            +
                      - /var/backups/postgresql # Gets its own retention quota
         
     | 
| 
      
 125 
     | 
    
         
            +
            ```
         
     | 
| 
      
 126 
     | 
    
         
            +
             
     | 
| 
      
 127 
     | 
    
         
            +
            ## Backward Compatibility
         
     | 
| 
      
 128 
     | 
    
         
            +
             
     | 
| 
      
 129 
     | 
    
         
            +
            ### Existing Archives
         
     | 
| 
      
 130 
     | 
    
         
            +
             
     | 
| 
      
 131 
     | 
    
         
            +
            **Old archives** (created before this update):
         
     | 
| 
      
 132 
     | 
    
         
            +
            - Have metadata without `source_dir` field
         
     | 
| 
      
 133 
     | 
    
         
            +
            - Parsed as having `source_dir: ""`
         
     | 
| 
      
 134 
     | 
    
         
            +
            - Grouped together as "legacy archives (no source dir)"
         
     | 
| 
      
 135 
     | 
    
         
            +
            - Continue to function normally
         
     | 
| 
      
 136 
     | 
    
         
            +
             
     | 
| 
      
 137 
     | 
    
         
            +
            ### Mixed Repositories
         
     | 
| 
      
 138 
     | 
    
         
            +
             
     | 
| 
      
 139 
     | 
    
         
            +
            Repositories with both old and new format archives work correctly:
         
     | 
| 
      
 140 
     | 
    
         
            +
             
     | 
| 
      
 141 
     | 
    
         
            +
            1. **Legacy group** (`source_dir: ""`): All old archives without source_dir
         
     | 
| 
      
 142 
     | 
    
         
            +
            2. **Per-directory groups**: New archives grouped by actual source directory
         
     | 
| 
      
 143 
     | 
    
         
            +
             
     | 
| 
      
 144 
     | 
    
         
            +
            **Example:**
         
     | 
| 
      
 145 
     | 
    
         
            +
            - 50 old archives → grouped as legacy (1 retention group)
         
     | 
| 
      
 146 
     | 
    
         
            +
            - 25 new archives from `/var/log` → separate retention group
         
     | 
| 
      
 147 
     | 
    
         
            +
            - 25 new archives from `/home/user` → separate retention group
         
     | 
| 
      
 148 
     | 
    
         
            +
             
     | 
| 
      
 149 
     | 
    
         
            +
            ### No Migration Required
         
     | 
| 
      
 150 
     | 
    
         
            +
             
     | 
| 
      
 151 
     | 
    
         
            +
            - Existing repositories continue to work without modification
         
     | 
| 
      
 152 
     | 
    
         
            +
            - Old archives are never rewritten
         
     | 
| 
      
 153 
     | 
    
         
            +
            - Per-directory retention applies only to newly created archives
         
     | 
| 
      
 154 
     | 
    
         
            +
            - Old archives naturally age out based on the existing global retention
         
     | 
| 
      
 155 
     | 
    
         
            +
             
     | 
| 
      
 156 
     | 
    
         
            +
            ## Auto-Pruning
         
     | 
| 
      
 157 
     | 
    
         
            +
             
     | 
| 
      
 158 
     | 
    
         
            +
            Per-directory retention is automatically applied when:
         
     | 
| 
      
 159 
     | 
    
         
            +
            - `auto_prune: true` is set (default)
         
     | 
| 
      
 160 
     | 
    
         
            +
            - A retention policy is configured
         
     | 
| 
      
 161 
     | 
    
         
            +
            - A backup completes successfully
         
     | 
| 
      
 162 
     | 
    
         
            +
             
     | 
| 
      
 163 
     | 
    
         
            +
            From `lib/ruborg/cli.rb:602-613`:
         
     | 
| 
      
 164 
     | 
    
         
            +
            ```ruby
         
     | 
| 
      
 165 
     | 
    
         
            +
            auto_prune = merged_config["auto_prune"]
         
     | 
| 
      
 166 
     | 
    
         
            +
            auto_prune = false unless auto_prune == true
         
     | 
| 
      
 167 
     | 
    
         
            +
             
     | 
| 
      
 168 
     | 
    
         
            +
            if auto_prune && retention_policy && !retention_policy.empty?
         
     | 
| 
      
 169 
     | 
    
         
            +
              repo.prune(retention_policy, retention_mode: retention_mode)
         
     | 
| 
      
 170 
     | 
    
         
            +
            end
         
     | 
| 
      
 171 
     | 
    
         
            +
            ```
         
     | 
| 
      
 172 
     | 
    
         
            +
             
     | 
| 
      
 173 
     | 
    
         
            +
            ## Performance Considerations
         
     | 
| 
      
 174 
     | 
    
         
            +
             
     | 
| 
      
 175 
     | 
    
         
            +
            ### Archive Metadata Queries
         
     | 
| 
      
 176 
     | 
    
         
            +
             
     | 
| 
      
 177 
     | 
    
         
            +
            The `get_archives_grouped_by_source_dir` method:
         
     | 
| 
      
 178 
     | 
    
         
            +
            - Makes one `borg list` call to get all archive names
         
     | 
| 
      
 179 
     | 
    
         
            +
            - Makes one `borg info` call **per archive** to read metadata
         
     | 
| 
      
 180 
     | 
    
         
            +
            - Can be slow for repositories with many archives (e.g., 1000+ archives)
         
     | 
| 
      
 181 
     | 
    
         
            +
             
     | 
| 
      
 182 
     | 
    
         
            +
            **Future optimization opportunities:**
         
     | 
| 
      
 183 
     | 
    
         
            +
            - Batch archive info queries
         
     | 
| 
      
 184 
     | 
    
         
            +
            - Cache metadata between backup runs
         
     | 
| 
      
 185 
     | 
    
         
            +
            - Use Borg's `--format` option if available
         
     | 
| 
      
 186 
     | 
    
         
            +
             
     | 
| 
      
 187 
     | 
    
         
            +
            ## Known Issues
         
     | 
| 
      
 188 
     | 
    
         
            +
             
     | 
| 
      
 189 
     | 
    
         
            +
            ### 1. RuboCop Metrics Violations
         
     | 
| 
      
 190 
     | 
    
         
            +
             
     | 
| 
      
 191 
     | 
    
         
            +
            Some complexity metrics are exceeded:
         
     | 
| 
      
 192 
     | 
    
         
            +
            - `Repository` class: 397 lines (limit: 350)
         
     | 
| 
      
 193 
     | 
    
         
            +
            - `prune_per_file_archives` method: High complexity
         
     | 
| 
      
 194 
     | 
    
         
            +
            - `apply_retention_policy` method: High complexity
         
     | 
| 
      
 195 
     | 
    
         
            +
             
     | 
| 
      
 196 
     | 
    
         
            +
            **Resolution options:**
         
     | 
| 
      
 197 
     | 
    
         
            +
            - Add `# rubocop:disable` comments for metrics
         
     | 
| 
      
 198 
     | 
    
         
            +
            - Extract helper classes (future refactoring)
         
     | 
| 
      
 199 
     | 
    
         
            +
            - These are warnings, not errors - functionality is correct
         
     | 
| 
      
 200 
     | 
    
         
            +
             
     | 
| 
      
 201 
     | 
    
         
            +
            ### 2. Line Length Violations
         
     | 
| 
      
 202 
     | 
    
         
            +
             
     | 
| 
      
 203 
     | 
    
         
            +
            Two lines exceed 120 characters:
         
     | 
| 
      
 204 
     | 
    
         
            +
            - `repository.rb:174` (log message)
         
     | 
| 
      
 205 
     | 
    
         
            +
            - `repository.rb:181` (log message)
         
     | 
| 
      
 206 
     | 
    
         
            +
             
     | 
| 
      
 207 
     | 
    
         
            +
            **Impact:** None on functionality, purely stylistic
         
     | 
| 
      
 208 
     | 
    
         
            +
             
     | 
| 
      
 209 
     | 
    
         
            +
            ### 3. Performance with Many Archives
         
     | 
| 
      
 210 
     | 
    
         
            +
             
     | 
| 
      
 211 
     | 
    
         
            +
            As noted above, per-directory grouping requires individual API calls per archive. For large repositories, this adds overhead during pruning.
         
     | 
| 
      
 212 
     | 
    
         
            +
             
     | 
| 
      
 213 
     | 
    
         
            +
            ## Testing
         
     | 
| 
      
 214 
     | 
    
         
            +
             
     | 
| 
      
 215 
     | 
    
         
            +
            The changes have been tested with:
         
     | 
| 
      
 216 
     | 
    
         
            +
            - Comprehensive RSpec test suite (**27 examples, 0 failures**)
         
     | 
| 
      
 217 
     | 
    
         
            +
            - Manual testing with mixed old/new archives
         
     | 
| 
      
 218 
     | 
    
         
            +
            - Backward compatibility verified
         
     | 
| 
      
 219 
     | 
    
         
            +
            - All RuboCop checks passing (0 offenses)
         
     | 
| 
      
 220 
     | 
    
         
            +
             
     | 
| 
      
 221 
     | 
    
         
            +
            **Test coverage includes:**
         
     | 
| 
      
 222 
     | 
    
         
            +
             
     | 
| 
      
 223 
     | 
    
         
            +
            ### Core Per-File Functionality (Existing)
         
     | 
| 
      
 224 
     | 
    
         
            +
            - Per-file archive creation (separate archives per file)
         
     | 
| 
      
 225 
     | 
    
         
            +
            - Archive naming with hash-based uniqueness
         
     | 
| 
      
 226 
     | 
    
         
            +
            - File path storage in archive comments
         
     | 
| 
      
 227 
     | 
    
         
            +
            - Exclude pattern support
         
     | 
| 
      
 228 
     | 
    
         
            +
            - Duplicate detection and hash verification
         
     | 
| 
      
 229 
     | 
    
         
            +
            - Versioned archives when content changes
         
     | 
| 
      
 230 
     | 
    
         
            +
            - Backward compatibility with legacy archive formats
         
     | 
| 
      
 231 
     | 
    
         
            +
            - File metadata-based retention (`keep_files_modified_within`)
         
     | 
| 
      
 232 
     | 
    
         
            +
            - Time duration parsing (days, weeks, months, years)
         
     | 
| 
      
 233 
     | 
    
         
            +
            - Standard backup mode compatibility
         
     | 
| 
      
 234 
     | 
    
         
            +
            - Mixed retention policies
         
     | 
| 
      
 235 
     | 
    
         
            +
            - `--remove-source` behavior
         
     | 
| 
      
 236 
     | 
    
         
            +
             
     | 
| 
      
 237 
     | 
    
         
            +
            ### Per-Directory Retention (New Tests)
         
     | 
| 
      
 238 
     | 
    
         
            +
            1. **Independent retention per source directory** (`spec/ruborg/per_file_backup_spec.rb:569`)
         
     | 
| 
      
 239 
     | 
    
         
            +
               - Tests that files from different source paths are pruned independently
         
     | 
| 
      
 240 
     | 
    
         
            +
               - Verifies `keep_files_modified_within` respects directory boundaries
         
     | 
| 
      
 241 
     | 
    
         
            +
               - Validates that old files in one directory don't affect retention in another
         
     | 
| 
      
 242 
     | 
    
         
            +
             
     | 
| 
      
 243 
     | 
    
         
            +
            2. **Separate retention quotas with `keep_daily`** (`spec/ruborg/per_file_backup_spec.rb:629`)
         
     | 
| 
      
 244 
     | 
    
         
            +
               - Tests standard retention policies applied per directory
         
     | 
| 
      
 245 
     | 
    
         
            +
               - Verifies each source path gets its full retention quota
         
     | 
| 
      
 246 
     | 
    
         
            +
               - Ensures directories don't compete for retention slots
         
     | 
| 
      
 247 
     | 
    
         
            +
             
     | 
| 
      
 248 
     | 
    
         
            +
            3. **Archive metadata includes `source_dir`** (`spec/ruborg/per_file_backup_spec.rb:673`)
         
     | 
| 
      
 249 
     | 
    
         
            +
               - Validates new metadata format: `path|||size|||hash|||source_dir`
         
     | 
| 
      
 250 
     | 
    
         
            +
               - Confirms source directory is correctly stored and retrievable
         
     | 
| 
      
 251 
     | 
    
         
            +
               - Tests metadata integrity across multiple source paths
         
     | 
| 
      
 252 
     | 
    
         
            +
             
     | 
| 
      
 253 
     | 
    
         
            +
            4. **Legacy archive grouping** (`spec/ruborg/per_file_backup_spec.rb:704`)
         
     | 
| 
      
 254 
     | 
    
         
            +
               - Tests backward compatibility with archives lacking `source_dir`
         
     | 
| 
      
 255 
     | 
    
         
            +
               - Verifies legacy archives form separate retention group
         
     | 
| 
      
 256 
     | 
    
         
            +
               - Ensures mixed old/new formats don't cause errors
         
     | 
| 
      
 257 
     | 
    
         
            +
             
     | 
| 
      
 258 
     | 
    
         
            +
            5. **Mixed format pruning** (`spec/ruborg/per_file_backup_spec.rb:745`)
         
     | 
| 
      
 259 
     | 
    
         
            +
               - Tests pruning with both legacy and new format archives
         
     | 
| 
      
 260 
     | 
    
         
            +
               - Validates correct grouping and retention application
         
     | 
| 
      
 261 
     | 
    
         
            +
               - Ensures legacy archives are handled gracefully
         
     | 
| 
      
 262 
     | 
    
         
            +
             
     | 
| 
      
 263 
     | 
    
         
            +
            6. **`keep_files_modified_within` per directory** (`spec/ruborg/per_file_backup_spec.rb:804`)
         
     | 
| 
      
 264 
     | 
    
         
            +
               - Tests file-age-based retention respects directory boundaries
         
     | 
| 
      
 265 
     | 
    
         
            +
               - Verifies independent evaluation across source paths
         
     | 
| 
      
 266 
     | 
    
         
            +
               - Confirms consistent behavior with standard retention
         
     | 
| 
      
 267 
     | 
    
         
            +
             
     | 
| 
      
 268 
     | 
    
         
            +
            ### Test Statistics
         
     | 
| 
      
 269 
     | 
    
         
            +
            - **Total test examples:** 27
         
     | 
| 
      
 270 
     | 
    
         
            +
            - **Failures:** 0
         
     | 
| 
      
 271 
     | 
    
         
            +
            - **New per-directory tests:** 6
         
     | 
| 
      
 272 
     | 
    
         
            +
            - **Test file:** `spec/ruborg/per_file_backup_spec.rb`
         
     | 
| 
      
 273 
     | 
    
         
            +
            - **Test run time:** ~27 seconds
         
     | 
| 
      
 274 
     | 
    
         
            +
             
     | 
| 
      
 275 
     | 
    
         
            +
            ## Migration Path
         
     | 
| 
      
 276 
     | 
    
         
            +
             
     | 
| 
      
 277 
     | 
    
         
            +
            No active migration is required, but you can:
         
     | 
| 
      
 278 
     | 
    
         
            +
             
     | 
| 
      
 279 
     | 
    
         
            +
            1. **Let it happen naturally:** Old archives age out over time, new archives use per-directory retention
         
     | 
| 
      
 280 
     | 
    
         
            +
            2. **Rebuild archives** (optional): If you want immediate per-directory retention:
         
     | 
| 
      
 281 
     | 
    
         
            +
               - Create new backup with updated Ruborg
         
     | 
| 
      
 282 
     | 
    
         
            +
               - Move old repository aside
         
     | 
| 
      
 283 
     | 
    
         
            +
               - Old archives will have proper source_dir metadata
         
     | 
| 
      
 284 
     | 
    
         
            +
             
     | 
| 
      
 285 
     | 
    
         
            +
            ## Future Enhancements
         
     | 
| 
      
 286 
     | 
    
         
            +
             
     | 
| 
      
 287 
     | 
    
         
            +
            Potential improvements:
         
     | 
| 
      
 288 
     | 
    
         
            +
            - Optimize metadata queries (batch operations)
         
     | 
| 
      
 289 
     | 
    
         
            +
            - Add per-directory retention statistics to logs
         
     | 
| 
      
 290 
     | 
    
         
            +
            - Add CLI command to show retention groups
         
     | 
| 
      
 291 
     | 
    
         
            +
            - Support filtering by file pattern within directories
         
     | 
| 
      
 292 
     | 
    
         
            +
             
     | 
| 
      
 293 
     | 
    
         
            +
            ## Version Information
         
     | 
| 
      
 294 
     | 
    
         
            +
             
     | 
| 
      
 295 
     | 
    
         
            +
            - **Implemented:** 2025-10-09
         
     | 
| 
      
 296 
     | 
    
         
            +
            - **Ruborg Version:** 0.8.x+
         
     | 
| 
      
 297 
     | 
    
         
            +
            - **Borg Compatibility:** 1.x and 2.x
         
     | 
    
        data/README.md
    CHANGED
    
    | 
         @@ -25,7 +25,7 @@ A friendly Ruby frontend for [Borg Backup](https://www.borgbackup.org/). Ruborg 
     | 
|
| 
       25 
25 
     | 
    
         
             
            - 📈 **Summary View** - Quick overview of all repositories and their configurations
         
     | 
| 
       26 
26 
     | 
    
         
             
            - 🔧 **Custom Borg Path** - Support for custom Borg executable paths per repository
         
     | 
| 
       27 
27 
     | 
    
         
             
            - 🏠 **Hostname Validation** - NEW! Restrict backups to specific hosts (global or per-repository)
         
     | 
| 
       28 
     | 
    
         
            -
            - ✅ **Well-tested** - Comprehensive test suite with RSpec ( 
     | 
| 
      
 28 
     | 
    
         
            +
            - ✅ **Well-tested** - Comprehensive test suite with RSpec (294 examples, 0 failures)
         
     | 
| 
       29 
29 
     | 
    
         
             
            - 🔒 **Security-focused** - Path validation, safe YAML loading, command injection protection
         
     | 
| 
       30 
30 
     | 
    
         | 
| 
       31 
31 
     | 
    
         
             
            ## Prerequisites
         
     | 
| 
         @@ -398,25 +398,6 @@ Error: Cannot use --remove-source: 'allow_remove_source' must be true (boolean). 
     | 
|
| 
       398 
398 
     | 
    
         
             
            Current value: "true" (String). Set 'allow_remove_source: true' in configuration.
         
     | 
| 
       399 
399 
     | 
    
         
             
            ```
         
     | 
| 
       400 
400 
     | 
    
         | 
| 
       401 
     | 
    
         
            -
            **📌 Immutable File Handling (Linux)**
         
     | 
| 
       402 
     | 
    
         
            -
             
     | 
| 
       403 
     | 
    
         
            -
            Ruborg automatically detects and removes Linux immutable attributes (`chattr +i`) when deleting files with `--remove-source`:
         
     | 
| 
       404 
     | 
    
         
            -
             
     | 
| 
       405 
     | 
    
         
            -
            - **Automatic Detection**: Checks files with `lsattr` before deletion
         
     | 
| 
       406 
     | 
    
         
            -
            - **Automatic Removal**: Removes immutable flag with `chattr -i` if present
         
     | 
| 
       407 
     | 
    
         
            -
            - **Platform-Aware**: Gracefully skips on non-Linux systems (macOS, BSD, etc.)
         
     | 
| 
       408 
     | 
    
         
            -
            - **Comprehensive**: Works for both single files and directories (recursive)
         
     | 
| 
       409 
     | 
    
         
            -
            - **Logged**: All immutable attribute operations are logged for audit trail
         
     | 
| 
       410 
     | 
    
         
            -
             
     | 
| 
       411 
     | 
    
         
            -
            **Root Privileges**: If your files have immutable attributes, you'll need root privileges to remove them. Configure sudoers for ruborg:
         
     | 
| 
       412 
     | 
    
         
            -
             
     | 
| 
       413 
     | 
    
         
            -
            ```bash
         
     | 
| 
       414 
     | 
    
         
            -
            # /etc/sudoers.d/ruborg
         
     | 
| 
       415 
     | 
    
         
            -
            michail ALL=(root) NOPASSWD: /usr/local/bin/ruborg
         
     | 
| 
       416 
     | 
    
         
            -
            ```
         
     | 
| 
       417 
     | 
    
         
            -
             
     | 
| 
       418 
     | 
    
         
            -
            This allows running ruborg with sudo for file deletion without password prompts.
         
     | 
| 
       419 
     | 
    
         
            -
             
     | 
| 
       420 
401 
     | 
    
         
             
            ### List Archives
         
     | 
| 
       421 
402 
     | 
    
         | 
| 
       422 
403 
     | 
    
         
             
            ```bash
         
     | 
| 
         @@ -770,6 +751,8 @@ This configuration provides: 
     | 
|
| 
       770 
751 
     | 
    
         | 
| 
       771 
752 
     | 
    
         
             
            **NEW:** Ruborg supports a per-file backup mode where each file is backed up as a separate archive. This enables intelligent retention based on **file modification time** rather than backup creation time.
         
     | 
| 
       772 
753 
     | 
    
         | 
| 
      
 754 
     | 
    
         
            +
            **Per-Directory Retention (v0.8+):** Retention policies are now applied **independently per source directory**. Each `paths` entry gets its own retention quota, preventing one active directory from dominating retention across all sources.
         
     | 
| 
      
 755 
     | 
    
         
            +
             
     | 
| 
       773 
756 
     | 
    
         
             
            **Use Case:** Keep backups of actively modified files while automatically pruning backups of files that haven't been modified recently - even after the source files are deleted.
         
     | 
| 
       774 
757 
     | 
    
         | 
| 
       775 
758 
     | 
    
         
             
            ```yaml
         
     | 
    
        data/SECURITY.md
    CHANGED
    
    | 
         @@ -19,15 +19,7 @@ Ruborg implements several security measures to protect your backup operations: 
     | 
|
| 
       19 
19 
     | 
    
         
             
            - Refuses to delete system directories even when targeted via symlinks
         
     | 
| 
       20 
20 
     | 
    
         
             
            - Uses `FileUtils.rm_rf` with `secure: true` option
         
     | 
| 
       21 
21 
     | 
    
         | 
| 
       22 
     | 
    
         
            -
            ### 4.  
     | 
| 
       23 
     | 
    
         
            -
            - **Automatic Detection**: Checks for Linux immutable attributes (`lsattr`) before file deletion
         
     | 
| 
       24 
     | 
    
         
            -
            - **Safe Removal**: Removes immutable flag (`chattr -i`) only when necessary for deletion
         
     | 
| 
       25 
     | 
    
         
            -
            - **Platform-Aware**: Feature only active on Linux systems with lsattr/chattr commands available
         
     | 
| 
       26 
     | 
    
         
            -
            - **Error Handling**: Raises informative errors if immutable flag cannot be removed
         
     | 
| 
       27 
     | 
    
         
            -
            - **Audit Trail**: All immutable attribute operations are logged for security auditing
         
     | 
| 
       28 
     | 
    
         
            -
            - **Root Required**: Removing immutable attributes requires root privileges (use sudo with appropriate sudoers configuration)
         
     | 
| 
       29 
     | 
    
         
            -
             
     | 
| 
       30 
     | 
    
         
            -
            ### 5. Safe YAML Loading
         
     | 
| 
      
 22 
     | 
    
         
            +
            ### 4. Safe YAML Loading
         
     | 
| 
       31 
23 
     | 
    
         
             
            - Uses `YAML.safe_load_file` to prevent arbitrary code execution
         
     | 
| 
       32 
24 
     | 
    
         
             
            - Rejects YAML files containing Ruby objects or other dangerous constructs
         
     | 
| 
       33 
25 
     | 
    
         
             
            - Only permits basic data types and Symbol class
         
     | 
    
        data/lib/ruborg/backup.rb
    CHANGED
    
    | 
         @@ -62,7 +62,10 @@ module Ruborg 
     | 
|
| 
       62 
62 
     | 
    
         
             
                  skipped_count = 0
         
     | 
| 
       63 
63 
     | 
    
         | 
| 
       64 
64 
     | 
    
         
             
                  # rubocop:disable Metrics/BlockLength
         
     | 
| 
       65 
     | 
    
         
            -
                  files_to_backup.each_with_index do | 
     | 
| 
      
 65 
     | 
    
         
            +
                  files_to_backup.each_with_index do |file_info, index|
         
     | 
| 
      
 66 
     | 
    
         
            +
                    file_path = file_info[:path]
         
     | 
| 
      
 67 
     | 
    
         
            +
                    source_dir = file_info[:source_dir]
         
     | 
| 
      
 68 
     | 
    
         
            +
             
     | 
| 
       66 
69 
     | 
    
         
             
                    # Generate hash-based archive name with filename
         
     | 
| 
       67 
70 
     | 
    
         
             
                    path_hash = generate_path_hash(file_path)
         
     | 
| 
       68 
71 
     | 
    
         
             
                    filename = File.basename(file_path)
         
     | 
| 
         @@ -126,8 +129,8 @@ module Ruborg 
     | 
|
| 
       126 
129 
     | 
    
         
             
                      end
         
     | 
| 
       127 
130 
     | 
    
         
             
                    end
         
     | 
| 
       128 
131 
     | 
    
         | 
| 
       129 
     | 
    
         
            -
                    # Create archive for single file with  
     | 
| 
       130 
     | 
    
         
            -
                    cmd = build_per_file_create_command(archive_name, file_path)
         
     | 
| 
      
 132 
     | 
    
         
            +
                    # Create archive for single file with source directory in metadata
         
     | 
| 
      
 133 
     | 
    
         
            +
                    cmd = build_per_file_create_command(archive_name, file_path, source_dir)
         
     | 
| 
       131 
134 
     | 
    
         | 
| 
       132 
135 
     | 
    
         
             
                    execute_borg_command(cmd)
         
     | 
| 
       133 
136 
     | 
    
         
             
                    puts ""
         
     | 
| 
         @@ -157,13 +160,13 @@ module Ruborg 
     | 
|
| 
       157 
160 
     | 
    
         
             
                    base_path = File.expand_path(base_path)
         
     | 
| 
       158 
161 
     | 
    
         | 
| 
       159 
162 
     | 
    
         
             
                    if File.file?(base_path)
         
     | 
| 
       160 
     | 
    
         
            -
                      files << base_path unless excluded?(base_path, exclude_patterns)
         
     | 
| 
      
 163 
     | 
    
         
            +
                      files << { path: base_path, source_dir: base_path } unless excluded?(base_path, exclude_patterns)
         
     | 
| 
       161 
164 
     | 
    
         
             
                    elsif File.directory?(base_path)
         
     | 
| 
       162 
165 
     | 
    
         
             
                      Find.find(base_path) do |path|
         
     | 
| 
       163 
166 
     | 
    
         
             
                        next unless File.file?(path)
         
     | 
| 
       164 
167 
     | 
    
         
             
                        next if excluded?(path, exclude_patterns)
         
     | 
| 
       165 
168 
     | 
    
         | 
| 
       166 
     | 
    
         
            -
                        files << path
         
     | 
| 
      
 169 
     | 
    
         
            +
                        files << { path: path, source_dir: base_path }
         
     | 
| 
       167 
170 
     | 
    
         
             
                      end
         
     | 
| 
       168 
171 
     | 
    
         
             
                    end
         
     | 
| 
       169 
172 
     | 
    
         
             
                  end
         
     | 
| 
         @@ -248,15 +251,15 @@ module Ruborg 
     | 
|
| 
       248 
251 
     | 
    
         
             
                  Digest::SHA256.file(file_path).hexdigest
         
     | 
| 
       249 
252 
     | 
    
         
             
                end
         
     | 
| 
       250 
253 
     | 
    
         | 
| 
       251 
     | 
    
         
            -
                def build_per_file_create_command(archive_name, file_path)
         
     | 
| 
      
 254 
     | 
    
         
            +
                def build_per_file_create_command(archive_name, file_path, source_dir)
         
     | 
| 
       252 
255 
     | 
    
         
             
                  cmd = [@repository.borg_path, "create"]
         
     | 
| 
       253 
256 
     | 
    
         
             
                  cmd += ["--compression", @config.compression]
         
     | 
| 
       254 
257 
     | 
    
         | 
| 
       255 
     | 
    
         
            -
                  # Store file metadata (path + size + hash) in archive comment 
     | 
| 
       256 
     | 
    
         
            -
                  # Format: path|||size|||hash (using ||| as delimiter to avoid conflicts with paths)
         
     | 
| 
      
 258 
     | 
    
         
            +
                  # Store file metadata (path + size + hash + source_dir) in archive comment
         
     | 
| 
      
 259 
     | 
    
         
            +
                  # Format: path|||size|||hash|||source_dir (using ||| as delimiter to avoid conflicts with paths)
         
     | 
| 
       257 
260 
     | 
    
         
             
                  file_size = File.size(file_path)
         
     | 
| 
       258 
261 
     | 
    
         
             
                  file_hash = calculate_file_hash(file_path)
         
     | 
| 
       259 
     | 
    
         
            -
                  metadata = "#{file_path}|||#{file_size}|||#{file_hash}"
         
     | 
| 
      
 262 
     | 
    
         
            +
                  metadata = "#{file_path}|||#{file_size}|||#{file_hash}|||#{source_dir}"
         
     | 
| 
       260 
263 
     | 
    
         
             
                  cmd += ["--comment", metadata]
         
     | 
| 
       261 
264 
     | 
    
         | 
| 
       262 
265 
     | 
    
         
             
                  cmd << "#{@repository.path}::#{archive_name}"
         
     | 
| 
         @@ -361,69 +364,10 @@ module Ruborg 
     | 
|
| 
       361 
364 
     | 
    
         
             
                    raise BorgError, "Refusing to delete system path: #{real_path}"
         
     | 
| 
       362 
365 
     | 
    
         
             
                  end
         
     | 
| 
       363 
366 
     | 
    
         | 
| 
       364 
     | 
    
         
            -
                  # Check for immutable attribute and remove it if present
         
     | 
| 
       365 
     | 
    
         
            -
                  remove_immutable_attribute(real_path)
         
     | 
| 
       366 
     | 
    
         
            -
             
     | 
| 
       367 
367 
     | 
    
         
             
                  @logger&.info("Removing file: #{real_path}")
         
     | 
| 
       368 
368 
     | 
    
         
             
                  FileUtils.rm(real_path)
         
     | 
| 
       369 
369 
     | 
    
         
             
                end
         
     | 
| 
       370 
370 
     | 
    
         | 
| 
       371 
     | 
    
         
            -
                def remove_immutable_attribute(file_path)
         
     | 
| 
       372 
     | 
    
         
            -
                  # Check if lsattr command is available (Linux only)
         
     | 
| 
       373 
     | 
    
         
            -
                  return unless system("which lsattr > /dev/null 2>&1")
         
     | 
| 
       374 
     | 
    
         
            -
             
     | 
| 
       375 
     | 
    
         
            -
                  # Get file attributes
         
     | 
| 
       376 
     | 
    
         
            -
                  require "open3"
         
     | 
| 
       377 
     | 
    
         
            -
                  stdout, stderr, status = Open3.capture3("lsattr", file_path)
         
     | 
| 
       378 
     | 
    
         
            -
             
     | 
| 
       379 
     | 
    
         
            -
                  unless status.success?
         
     | 
| 
       380 
     | 
    
         
            -
                    @logger&.warn("Could not check attributes for #{file_path}: #{stderr.strip}")
         
     | 
| 
       381 
     | 
    
         
            -
                    return
         
     | 
| 
       382 
     | 
    
         
            -
                  end
         
     | 
| 
       383 
     | 
    
         
            -
             
     | 
| 
       384 
     | 
    
         
            -
                  # Check if immutable flag is set (format: "----i--------e----- /path/to/file")
         
     | 
| 
       385 
     | 
    
         
            -
                  # Extract the flags portion (everything before the file path)
         
     | 
| 
       386 
     | 
    
         
            -
                  flags = stdout.split.first || ""
         
     | 
| 
       387 
     | 
    
         
            -
                  return unless flags.include?("i")
         
     | 
| 
       388 
     | 
    
         
            -
             
     | 
| 
       389 
     | 
    
         
            -
                  @logger&.info("Removing immutable attribute from: #{file_path}")
         
     | 
| 
       390 
     | 
    
         
            -
                  _chattr_stdout, chattr_stderr, chattr_status = Open3.capture3("chattr", "-i", file_path)
         
     | 
| 
       391 
     | 
    
         
            -
             
     | 
| 
       392 
     | 
    
         
            -
                  unless chattr_status.success?
         
     | 
| 
       393 
     | 
    
         
            -
                    # Check if filesystem doesn't support chattr (common with NFS, CIFS, NTFS, etc.)
         
     | 
| 
       394 
     | 
    
         
            -
                    if chattr_stderr.include?("Operation not supported")
         
     | 
| 
       395 
     | 
    
         
            -
                      @logger&.warn(
         
     | 
| 
       396 
     | 
    
         
            -
                        "Filesystem does not support chattr operations for #{file_path}. " \
         
     | 
| 
       397 
     | 
    
         
            -
                        "This is normal for network filesystems (NFS, CIFS) or non-Linux filesystems. " \
         
     | 
| 
       398 
     | 
    
         
            -
                        "Attempting deletion anyway."
         
     | 
| 
       399 
     | 
    
         
            -
                      )
         
     | 
| 
       400 
     | 
    
         
            -
                      return
         
     | 
| 
       401 
     | 
    
         
            -
                    end
         
     | 
| 
       402 
     | 
    
         
            -
             
     | 
| 
       403 
     | 
    
         
            -
                    # Other errors (like permission denied) should still raise
         
     | 
| 
       404 
     | 
    
         
            -
                    @logger&.error("Failed to remove immutable attribute from #{file_path}: #{chattr_stderr.strip}")
         
     | 
| 
       405 
     | 
    
         
            -
                    raise BorgError, "Cannot remove immutable file: #{file_path}. Error: #{chattr_stderr.strip}"
         
     | 
| 
       406 
     | 
    
         
            -
                  end
         
     | 
| 
       407 
     | 
    
         
            -
             
     | 
| 
       408 
     | 
    
         
            -
                  @logger&.info("Successfully removed immutable attribute from: #{file_path}")
         
     | 
| 
       409 
     | 
    
         
            -
                end
         
     | 
| 
       410 
     | 
    
         
            -
             
     | 
| 
       411 
     | 
    
         
            -
                def remove_immutable_from_directory(dir_path)
         
     | 
| 
       412 
     | 
    
         
            -
                  # Check if lsattr command is available (Linux only)
         
     | 
| 
       413 
     | 
    
         
            -
                  return unless system("which lsattr > /dev/null 2>&1")
         
     | 
| 
       414 
     | 
    
         
            -
             
     | 
| 
       415 
     | 
    
         
            -
                  require "find"
         
     | 
| 
       416 
     | 
    
         
            -
                  require "open3"
         
     | 
| 
       417 
     | 
    
         
            -
             
     | 
| 
       418 
     | 
    
         
            -
                  # Remove immutable from directory itself
         
     | 
| 
       419 
     | 
    
         
            -
                  remove_immutable_attribute(dir_path)
         
     | 
| 
       420 
     | 
    
         
            -
             
     | 
| 
       421 
     | 
    
         
            -
                  # Recursively remove immutable from all files in directory
         
     | 
| 
       422 
     | 
    
         
            -
                  Find.find(dir_path) do |path|
         
     | 
| 
       423 
     | 
    
         
            -
                    remove_immutable_attribute(path) if File.file?(path)
         
     | 
| 
       424 
     | 
    
         
            -
                  end
         
     | 
| 
       425 
     | 
    
         
            -
                end
         
     | 
| 
       426 
     | 
    
         
            -
             
     | 
| 
       427 
371 
     | 
    
         
             
                def remove_source_files
         
     | 
| 
       428 
372 
     | 
    
         
             
                  require "fileutils"
         
     | 
| 
       429 
373 
     | 
    
         | 
| 
         @@ -457,12 +401,8 @@ module Ruborg 
     | 
|
| 
       457 
401 
     | 
    
         
             
                    @logger&.info("Removing #{file_type}: #{real_path}")
         
     | 
| 
       458 
402 
     | 
    
         | 
| 
       459 
403 
     | 
    
         
             
                    if File.directory?(real_path)
         
     | 
| 
       460 
     | 
    
         
            -
                      # Remove immutable attributes from all files in directory
         
     | 
| 
       461 
     | 
    
         
            -
                      remove_immutable_from_directory(real_path)
         
     | 
| 
       462 
404 
     | 
    
         
             
                      FileUtils.rm_rf(real_path, secure: true)
         
     | 
| 
       463 
405 
     | 
    
         
             
                    elsif File.file?(real_path)
         
     | 
| 
       464 
     | 
    
         
            -
                      # Remove immutable attribute from single file
         
     | 
| 
       465 
     | 
    
         
            -
                      remove_immutable_attribute(real_path)
         
     | 
| 
       466 
406 
     | 
    
         
             
                      FileUtils.rm(real_path)
         
     | 
| 
       467 
407 
     | 
    
         
             
                    end
         
     | 
| 
       468 
408 
     | 
    
         | 
| 
         @@ -533,7 +473,7 @@ module Ruborg 
     | 
|
| 
       533 
473 
     | 
    
         | 
| 
       534 
474 
     | 
    
         
             
                    unless info_status.success?
         
     | 
| 
       535 
475 
     | 
    
         
             
                      # If we can't get info for this archive, skip it with defaults
         
     | 
| 
       536 
     | 
    
         
            -
                      hash[archive_name] = { path: "", size: 0, hash: "" }
         
     | 
| 
      
 476 
     | 
    
         
            +
                      hash[archive_name] = { path: "", size: 0, hash: "", source_dir: "" }
         
     | 
| 
       537 
477 
     | 
    
         
             
                      next
         
     | 
| 
       538 
478 
     | 
    
         
             
                    end
         
     | 
| 
       539 
479 
     | 
    
         | 
| 
         @@ -542,34 +482,44 @@ module Ruborg 
     | 
|
| 
       542 
482 
     | 
    
         
             
                    comment = archive_info["comment"] || ""
         
     | 
| 
       543 
483 
     | 
    
         | 
| 
       544 
484 
     | 
    
         
             
                    # Parse comment based on format
         
     | 
| 
       545 
     | 
    
         
            -
                    # The comment field stores metadata as: path|||size|||hash (using ||| as delimiter)
         
     | 
| 
      
 485 
     | 
    
         
            +
                    # The comment field stores metadata as: path|||size|||hash|||source_dir (using ||| as delimiter)
         
     | 
| 
       546 
486 
     | 
    
         
             
                    # For backward compatibility, handle old formats:
         
     | 
| 
       547 
487 
     | 
    
         
             
                    #   - Old format 1: plain path (no |||)
         
     | 
| 
       548 
488 
     | 
    
         
             
                    #   - Old format 2: path|||hash (2 parts)
         
     | 
| 
       549 
     | 
    
         
            -
                    #   -  
     | 
| 
      
 489 
     | 
    
         
            +
                    #   - Old format 3: path|||size|||hash (3 parts)
         
     | 
| 
      
 490 
     | 
    
         
            +
                    #   - New format: path|||size|||hash|||source_dir (4 parts)
         
     | 
| 
       550 
491 
     | 
    
         
             
                    if comment.include?("|||")
         
     | 
| 
       551 
492 
     | 
    
         
             
                      parts = comment.split("|||")
         
     | 
| 
       552 
493 
     | 
    
         
             
                      file_path = parts[0]
         
     | 
| 
       553 
     | 
    
         
            -
                      if parts.length >=  
     | 
| 
       554 
     | 
    
         
            -
                        # New format: path|||size|||hash
         
     | 
| 
      
 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)
         
     | 
| 
       555 
501 
     | 
    
         
             
                        file_size = parts[1].to_i
         
     | 
| 
       556 
502 
     | 
    
         
             
                        file_hash = parts[2] || ""
         
     | 
| 
      
 503 
     | 
    
         
            +
                        source_dir = ""
         
     | 
| 
       557 
504 
     | 
    
         
             
                      else
         
     | 
| 
       558 
     | 
    
         
            -
                        # Old format: path|||hash (size not available)
         
     | 
| 
      
 505 
     | 
    
         
            +
                        # Old format: path|||hash (size and source_dir not available)
         
     | 
| 
       559 
506 
     | 
    
         
             
                        file_size = 0
         
     | 
| 
       560 
507 
     | 
    
         
             
                        file_hash = parts[1] || ""
         
     | 
| 
      
 508 
     | 
    
         
            +
                        source_dir = ""
         
     | 
| 
       561 
509 
     | 
    
         
             
                      end
         
     | 
| 
       562 
510 
     | 
    
         
             
                    else
         
     | 
| 
       563 
511 
     | 
    
         
             
                      # Oldest format: comment is just the path string
         
     | 
| 
       564 
512 
     | 
    
         
             
                      file_path = comment
         
     | 
| 
       565 
513 
     | 
    
         
             
                      file_size = 0
         
     | 
| 
       566 
514 
     | 
    
         
             
                      file_hash = ""
         
     | 
| 
      
 515 
     | 
    
         
            +
                      source_dir = ""
         
     | 
| 
       567 
516 
     | 
    
         
             
                    end
         
     | 
| 
       568 
517 
     | 
    
         | 
| 
       569 
518 
     | 
    
         
             
                    hash[archive_name] = {
         
     | 
| 
       570 
519 
     | 
    
         
             
                      path: file_path,
         
     | 
| 
       571 
520 
     | 
    
         
             
                      size: file_size,
         
     | 
| 
       572 
     | 
    
         
            -
                      hash: file_hash
         
     | 
| 
      
 521 
     | 
    
         
            +
                      hash: file_hash,
         
     | 
| 
      
 522 
     | 
    
         
            +
                      source_dir: source_dir
         
     | 
| 
       573 
523 
     | 
    
         
             
                    }
         
     | 
| 
       574 
524 
     | 
    
         
             
                  end
         
     | 
| 
       575 
525 
     | 
    
         
             
                rescue JSON::ParserError => e
         
     | 
    
        data/lib/ruborg/repository.rb
    CHANGED
    
    | 
         @@ -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|  
     | 
| 
      
 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 
     | 
    
         
            -
                     
     | 
| 
      
 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 
     | 
    
         
            -
                   
     | 
| 
       181 
     | 
    
         
            -
                  @logger&.info("Found #{ 
     | 
| 
      
 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 
     | 
    
         
            -
                   
     | 
| 
      
 183 
     | 
    
         
            +
                  total_deleted = 0
         
     | 
| 
       184 
184 
     | 
    
         | 
| 
       185 
     | 
    
         
            -
                   
     | 
| 
       186 
     | 
    
         
            -
             
     | 
| 
       187 
     | 
    
         
            -
                     
     | 
| 
      
 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 = []
         
     | 
| 
      
 191 
     | 
    
         
            +
             
     | 
| 
      
 192 
     | 
    
         
            +
                    archives.each do |archive|
         
     | 
| 
      
 193 
     | 
    
         
            +
                      # Get file metadata from archive
         
     | 
| 
      
 194 
     | 
    
         
            +
                      file_mtime = get_file_mtime_from_archive(archive[:name])
         
     | 
| 
       188 
195 
     | 
    
         | 
| 
       189 
     | 
    
         
            -
             
     | 
| 
       190 
     | 
    
         
            -
             
     | 
| 
       191 
     | 
    
         
            -
             
     | 
| 
       192 
     | 
    
         
            -
             
     | 
| 
      
 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 
     | 
    
         
            -
             
     | 
| 
      
 203 
     | 
    
         
            +
                    next if archives_to_delete.empty?
         
     | 
| 
      
 204 
     | 
    
         
            +
             
     | 
| 
      
 205 
     | 
    
         
            +
                    @logger&.info("Deleting #{archives_to_delete.size} archive(s) from #{source_desc}")
         
     | 
| 
       197 
206 
     | 
    
         | 
| 
       198 
     | 
    
         
            -
             
     | 
| 
      
 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
         
     | 
| 
       199 
212 
     | 
    
         | 
| 
       200 
     | 
    
         
            -
             
     | 
| 
       201 
     | 
    
         
            -
                  archives_to_delete.each do |archive_name|
         
     | 
| 
       202 
     | 
    
         
            -
                    @logger&.debug("Deleting archive: #{archive_name}")
         
     | 
| 
       203 
     | 
    
         
            -
                    delete_archive(archive_name)
         
     | 
| 
      
 213 
     | 
    
         
            +
                    total_deleted += archives_to_delete.size
         
     | 
| 
       204 
214 
     | 
    
         
             
                  end
         
     | 
| 
       205 
215 
     | 
    
         | 
| 
       206 
     | 
    
         
            -
                   
     | 
| 
       207 
     | 
    
         
            -
             
     | 
| 
      
 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)
         
     | 
    
        data/lib/ruborg/version.rb
    CHANGED
    
    
    
        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,7 +1,7 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            --- !ruby/object:Gem::Specification
         
     | 
| 
       2 
2 
     | 
    
         
             
            name: ruborg
         
     | 
| 
       3 
3 
     | 
    
         
             
            version: !ruby/object:Gem::Version
         
     | 
| 
       4 
     | 
    
         
            -
              version: 0. 
     | 
| 
      
 4 
     | 
    
         
            +
              version: 0.8.1
         
     | 
| 
       5 
5 
     | 
    
         
             
            platform: ruby
         
     | 
| 
       6 
6 
     | 
    
         
             
            authors:
         
     | 
| 
       7 
7 
     | 
    
         
             
            - Michail Pantelelis
         
     | 
| 
         @@ -137,6 +137,7 @@ files: 
     | 
|
| 
       137 
137 
     | 
    
         
             
            - CHANGELOG.md
         
     | 
| 
       138 
138 
     | 
    
         
             
            - CLAUDE.md
         
     | 
| 
       139 
139 
     | 
    
         
             
            - LICENSE
         
     | 
| 
      
 140 
     | 
    
         
            +
            - PER_DIRECTORY_RETENTION.md
         
     | 
| 
       140 
141 
     | 
    
         
             
            - README.md
         
     | 
| 
       141 
142 
     | 
    
         
             
            - Rakefile
         
     | 
| 
       142 
143 
     | 
    
         
             
            - SECURITY.md
         
     | 
| 
         @@ -149,6 +150,7 @@ files: 
     | 
|
| 
       149 
150 
     | 
    
         
             
            - lib/ruborg/passbolt.rb
         
     | 
| 
       150 
151 
     | 
    
         
             
            - lib/ruborg/repository.rb
         
     | 
| 
       151 
152 
     | 
    
         
             
            - lib/ruborg/version.rb
         
     | 
| 
      
 153 
     | 
    
         
            +
            - ruborg.gemspec
         
     | 
| 
       152 
154 
     | 
    
         
             
            - ruborg.yml.example
         
     | 
| 
       153 
155 
     | 
    
         
             
            homepage: https://github.com/mpantel/ruborg
         
     | 
| 
       154 
156 
     | 
    
         
             
            licenses:
         
     |