diff --git a/lib/ruborg/cli.rb b/lib/ruborg/cli.rb index 94dd52b..5ce5aa1 100644 --- a/lib/ruborg/cli.rb +++ b/lib/ruborg/cli.rb @@ -177,6 +177,9 @@ def info desc "validate TYPE", "Validate configuration file or repository (TYPE: config or repo)" option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower, only for 'repo' type)" option :all, type: :boolean, default: false, desc: "Validate all repositories (only for 'repo' type)" + option :repair, type: :boolean, default: false, + desc: "Repair corruption via borg check --repair (only for 'repo', requires --yes)" + option :yes, type: :boolean, default: false, desc: "Confirm destructive repair operation (required with --repair)" def validate(type) case type when "config" @@ -335,6 +338,19 @@ def validate_repository(repo_config, global_settings) @logger.info("Integrity check passed for #{repo_name}") end + # Run repair if requested + if options[:repair] + raise ConfigError, "Repair mode requires --yes to acknowledge potential data loss" unless options[:yes] + + puts " Running repair (borg check --repair)..." + puts " ⚠ WARNING: repair may delete corrupted archives" + @logger.warn("Starting repair on #{repo_name} — data loss possible if archives are corrupted") + output = repo.repair + puts " ✓ Repair completed" + output.split("\n").each { |line| puts " #{line}" } unless output.empty? + @logger.info("Repair completed for #{repo_name}:\n#{output}") + end + puts "" rescue BorgError => e puts " ✗ Validation failed: #{e.message}" diff --git a/lib/ruborg/repository.rb b/lib/ruborg/repository.rb index 8fda11f..05bf53a 100644 --- a/lib/ruborg/repository.rb +++ b/lib/ruborg/repository.rb @@ -514,6 +514,20 @@ def check execute_borg_command(cmd) end + def repair + raise BorgError, "Repository does not exist at #{@path}" unless exists? + + cmd = inject_lock_wait([@borg_path, "check", "--repair", @path]) + env = build_borg_env + + stdout, stderr, status = Open3.capture3(env, *cmd, stdin_data: "YES\n") + output = [stdout, stderr].map(&:strip).reject(&:empty?).join("\n") + + raise BorgError, "Borg repair failed:\n#{output}" unless status.success? + + output + end + # Get Borg version def self.borg_version(borg_path = "borg") output, status = execute_version_command(borg_path) diff --git a/spec/ruborg/cli_spec.rb b/spec/ruborg/cli_spec.rb index ad7aec2..dab53b3 100644 --- a/spec/ruborg/cli_spec.rb +++ b/spec/ruborg/cli_spec.rb @@ -1333,6 +1333,68 @@ end end + describe "validate repo --repair", :borg do + let(:repair_config) do + { + "repositories" => [ + { + "name" => "test-repo", + "path" => repo_path, + "sources" => [{ "name" => "main", "paths" => ["/tmp/test"] }] + } + ] + } + end + + before do + allow_any_instance_of(Ruborg::RuborgLogger).to receive(:info) + allow_any_instance_of(Ruborg::RuborgLogger).to receive(:warn) + allow_any_instance_of(Ruborg::RuborgLogger).to receive(:error) + allow(Ruborg::Repository).to receive(:borg_version).and_return("1.2.8") + end + + it "raises ConfigError when --repair is given without --yes" do + config_file = create_test_config(repair_config) + FileUtils.mkdir_p(repo_path) + repo = Ruborg::Repository.new(repo_path) + repo.create + + expect do + described_class.start(["validate", "repo", "--config", config_file, + "--repository", "test-repo", "--repair"]) + end.to raise_error(Ruborg::ConfigError, /--yes/) + end + + it "calls repo.repair and logs output when --repair --yes are given" do + config_file = create_test_config(repair_config) + FileUtils.mkdir_p(repo_path) + repo_instance = Ruborg::Repository.new(repo_path) + repo_instance.create + + allow_any_instance_of(Ruborg::Repository).to receive(:repair).and_return("Repaired 3 segments") + + expect do + described_class.start(["validate", "repo", "--config", config_file, + "--repository", "test-repo", "--repair", "--yes"]) + end.to output(/Repair completed/).to_stdout + end + + it "surfaces BorgError from repair as a failure message" do + config_file = create_test_config(repair_config) + FileUtils.mkdir_p(repo_path) + repo_instance = Ruborg::Repository.new(repo_path) + repo_instance.create + + allow_any_instance_of(Ruborg::Repository).to receive(:repair) + .and_raise(Ruborg::BorgError, "Borg repair failed: segment error") + + expect do + described_class.start(["validate", "repo", "--config", config_file, + "--repository", "test-repo", "--repair", "--yes"]) + end.to output(/Validation failed/).to_stdout + end + end + describe "lock command" do before do allow_any_instance_of(Ruborg::RuborgLogger).to receive(:info) diff --git a/spec/ruborg/repository_spec.rb b/spec/ruborg/repository_spec.rb index 861e4a6..0cd529b 100644 --- a/spec/ruborg/repository_spec.rb +++ b/spec/ruborg/repository_spec.rb @@ -305,6 +305,55 @@ end end + describe "#repair", :borg do + it "raises error if repository does not exist" do + repo = described_class.new(repo_path) + + expect do + repo.repair + end.to raise_error(Ruborg::BorgError, /does not exist/) + end + + it "runs borg check --repair piping YES to stdin and returns output" do + repo = described_class.new(repo_path, passphrase: passphrase) + repo.create + + fake_status = instance_double(Process::Status, success?: true) + expect(Open3).to receive(:capture3).with( + hash_including("BORG_PASSPHRASE" => passphrase), + "borg", "check", "--repair", repo_path, + stdin_data: "YES\n" + ).and_return(["Repair output", "", fake_status]) + + output = repo.repair + expect(output).to eq("Repair output") + end + + it "raises BorgError when borg check --repair fails" do + repo = described_class.new(repo_path, passphrase: passphrase) + repo.create + + fake_status = instance_double(Process::Status, success?: false) + allow(Open3).to receive(:capture3).and_return(["", "segment error", fake_status]) + + expect do + repo.repair + end.to raise_error(Ruborg::BorgError, /repair failed/) + end + + it "combines stdout and stderr in the returned output" do + repo = described_class.new(repo_path, passphrase: passphrase) + repo.create + + fake_status = instance_double(Process::Status, success?: true) + allow(Open3).to receive(:capture3).and_return(["stdout line", "stderr line", fake_status]) + + output = repo.repair + expect(output).to include("stdout line") + expect(output).to include("stderr line") + end + end + describe "#check_compatibility", :borg do it "raises error if repository does not exist" do repo = described_class.new(repo_path)