yobi 0.3.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +17 -0
  4. data/README.md +83 -44
  5. data/lib/yobi/argv_builder.rb +1 -35
  6. data/lib/yobi/cancellable_proxy.rb +48 -0
  7. data/lib/yobi/cancellation.rb +97 -0
  8. data/lib/yobi/errors.rb +45 -30
  9. data/lib/yobi/fancy_hash.rb +1 -5
  10. data/lib/yobi/io_handle.rb +9 -17
  11. data/lib/yobi/mount_handle.rb +7 -11
  12. data/lib/yobi/repository/backup.rb +59 -90
  13. data/lib/yobi/repository/cat.rb +23 -43
  14. data/lib/yobi/repository/check.rb +21 -34
  15. data/lib/yobi/repository/copy.rb +11 -13
  16. data/lib/yobi/repository/diff.rb +18 -49
  17. data/lib/yobi/repository/dump.rb +14 -15
  18. data/lib/yobi/repository/find.rb +17 -37
  19. data/lib/yobi/repository/forget.rb +35 -39
  20. data/lib/yobi/repository/init.rb +19 -24
  21. data/lib/yobi/repository/key.rb +18 -33
  22. data/lib/yobi/repository/list.rb +5 -6
  23. data/lib/yobi/repository/ls.rb +20 -43
  24. data/lib/yobi/repository/migrate.rb +6 -6
  25. data/lib/yobi/repository/mount.rb +22 -32
  26. data/lib/yobi/repository/prune.rb +11 -9
  27. data/lib/yobi/repository/recover.rb +2 -4
  28. data/lib/yobi/repository/repair.rb +16 -23
  29. data/lib/yobi/repository/restore.rb +31 -54
  30. data/lib/yobi/repository/rewrite.rb +15 -20
  31. data/lib/yobi/repository/snapshots.rb +7 -8
  32. data/lib/yobi/repository/stats.rb +26 -21
  33. data/lib/yobi/repository/tag.rb +23 -33
  34. data/lib/yobi/repository/unlock.rb +2 -4
  35. data/lib/yobi/repository.rb +96 -25
  36. data/lib/yobi/restic.rb +93 -114
  37. data/lib/yobi/restic_output.rb +2 -74
  38. data/lib/yobi/snapshot.rb +15 -27
  39. data/lib/yobi/version.rb +1 -2
  40. data/lib/yobi.rb +2 -0
  41. data/sig/yobi.rbs +83 -6
  42. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56739c72c75a1d5c856bbd619b781dd3986cf1b3c1447d4864edd19b9543d8d9
4
- data.tar.gz: a5321fe628cab2f74011ce257a4eee380ed1fcb804456f8afa5482ac84c96b6f
3
+ metadata.gz: cea1c8c245d4ba7aec5a218441f19921606c7bdb5d2b1ddc9912c2d9519ba15e
4
+ data.tar.gz: 66bf1c1c5e4ce94706948db27352889b8af41e6967c0c00406c303515181f710
5
5
  SHA512:
6
- metadata.gz: 9b2253d54dd64aa88658c7ddd53734a2edbcaf2623bb98258bc14d71d0d4a82bb3bcb447e6434bfb28505d2bfcd329988841d8f491ce7c3d1b13865b4a50b0f7
7
- data.tar.gz: 7cd169b509d216a99b258180381c9caa9cd92837b822467781b01c3788cc37af4252d825b4de50a511b71a521a4e8d9bff8317a7f9c0da11b82847c6b65c2b8c
6
+ metadata.gz: 89d14f78cd56cfcd7e48e3d56b94a2f5159ac5de5877872797b106307730d0d8c98702fd897ac5572f72d294fede4726e59cecfeab942e1fc29c1e15fe4184f3
7
+ data.tar.gz: d2c8ae8d702cebc1186c0f117abb542d1e80e84d4dc5e0ccac7ced97b278cd0762c0b70dfe2db84d968529aa235f12fbfd34b07252aa140d6bc2841cab372221
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.5
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## [1.1.0] - 2026-09-02
2
+
3
+ ### Added
4
+
5
+ - `Repository#with_cancellation(token)` and `Yobi::Cancellation`: stop a long-running `#backup`/`#restore`/`#check`/`#prune`/`#forget`/`#copy` from another thread via `token.cancel!`, raising `Yobi::Cancelled` if Restic exited with code 130.
6
+
7
+ ## [1.0.0] - 2026-08-29
8
+
9
+ ### Changed
10
+
11
+ - **Breaking:** minimum supported Restic bumped from 0.17.1 to 0.18.0. Restic 0.17.x completely ignored `--json` for `tag` and `check`, so those commands ran without error but returned empty `TagOutcome`/`CheckOutcome` objects. 0.18.0 is the first version emitting structured JSON for both. `Yobi::UnsupportedResticVersion` is now raised against any 0.17.x binary.
12
+
13
+ ### Added
14
+
15
+ - `Restic#restic_path=` setter.
16
+ - `Repository#url=`, `#password=`, and `#backend_credentials=` setters, validated on assignment with the same shapes `#initialize` accepts.
17
+
1
18
  ## [0.3.1] - 2026-08-10
2
19
 
3
20
  ### Fixed
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Yobi
2
2
 
3
- Yobi is a Ruby interface for the [Restic](https://restic.net/) backup program.
3
+ [![Gem Version](https://badge.fury.io/rb/yobi.svg)](https://badge.fury.io/rb/yobi)
4
+
5
+ A Ruby interface for the [Restic](https://restic.net/) backup program.
6
+
7
+ Tested against Restic 0.18.0, 0.18.1, 0.19.0, 0.19.1.
4
8
 
5
9
  ## Installation
6
10
 
@@ -30,7 +34,7 @@ restic version
30
34
 
31
35
  ### Minimum supported version
32
36
 
33
- Yobi requires Restic **0.17.1** or newer. This isn't arbitrary: `Yobi::Restic`'s exit-code-based error dispatch (mapping Restic's own exit codes to `Yobi::RepositoryNotFound`/`RepositoryLocked`/`AuthenticationFailed`) depends on a convention that's only guaranteed since that version. An older Restic could reuse those same exit codes for different failures, so rather than risk silently misclassifying an error, Yobi checks the installed version before running any real command and raises `Yobi::UnsupportedResticVersion` if it's too old:
37
+ Yobi requires Restic **0.18.0** or newer. Its typed errors (`Yobi::RepositoryNotFound`, `RepositoryLocked`, `AuthenticationFailed`) rely on stable exit codes, and its `#tag`/`#check` outcomes rely on structured `--json` output that older restic didn't emit for those commands. Yobi checks the installed version before running any real command and raises `Yobi::UnsupportedResticVersion` if it's too old:
34
38
 
35
39
  ```ruby
36
40
  begin
@@ -40,7 +44,7 @@ rescue Yobi::UnsupportedResticVersion => e
40
44
  end
41
45
  ```
42
46
 
43
- See ["Minimum-version enforcement, in detail"](#minimum-version-enforcement-in-detail) below for the mechanics. If you'd rather fail fast at application startup instead of on the first backup attempt, call it explicitly:
47
+ If you'd rather fail fast at application startup instead of on the first backup attempt, call it explicitly:
44
48
 
45
49
  ```ruby
46
50
  RESTIC = Yobi::Restic.new
@@ -216,6 +220,7 @@ The full hierarchy, all under `Yobi::Error < StandardError`:
216
220
  - `Yobi::RepositoryNotFound`, `Yobi::RepositoryLocked`, `Yobi::AuthenticationFailed`: Restic's own typed exit codes (10/11/12).
217
221
  - `Yobi::ResticCommandFailed`: any other non-zero exit, for a failure that doesn't fit one of the above.
218
222
  - `Yobi::MountTimeout`: `#mount` didn't report itself ready within its timeout.
223
+ - `Yobi::Cancelled`: the run was stopped through a `Yobi::Cancellation` token (see ["Cancelling a running command"](#cancelling-a-running-command) below).
219
224
 
220
225
  Every method below lives on `Yobi::Repository` unless noted otherwise.
221
226
 
@@ -230,7 +235,7 @@ repo.init
230
235
  # => #<Yobi::Initialized id="..." repository="...">
231
236
  ```
232
237
 
233
- `copy_chunker_params:` copies chunker parameters from another repository (`from_repo:`/`from_password:`/etc.), so a later `#copy` between the two can deduplicate - see `#init_mirror` under ["Across repositories"](#across-repositories) for a shortcut that sets this up in one call. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#init-instance_method) for the full list of options. Returns an `Initialized` (`#id`/`#repository`).
238
+ `copy_chunker_params:` copies chunker parameters from another repository (`from_repo:`/`from_password:`/etc.), so a later `#copy` between the two can deduplicate - see `#init_mirror` under ["Across repositories"](#across-repositories) for a shortcut that sets this up in one call. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#init-instance_method) for the full list of options. Returns an `Initialized` (`#id`/`#repository`).
234
239
 
235
240
  ### `#cat_config`
236
241
 
@@ -261,7 +266,7 @@ outcome.summary["data_added"]
261
266
 
262
267
  `source:` is either a path String, or a `[:stdin_from_command, command]`/`[:stdin_from_command, command, filename]` tuple: Restic spawns and executes `command` itself, capturing its stdout as the backup content (see ["Database dumps via `stdin_from_command`"](#database-dumps-via-stdin_from_command) below for a `pg_dump` example). `command` can be a String (tokenized with `Shellwords.split`, so quoted arguments survive) or an Array of already-discrete arguments (used as-is; needed when an argument itself contains a literal space, which `Shellwords` would otherwise split incorrectly).
263
268
 
264
- `host:` records a hostname on the *new* snapshot - a single value, since this is metadata being written, not a filter, unlike every `hosts:` elsewhere in this API. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#backup-instance_method) for the full list of options (filtering, retention, timing, and more).
269
+ `host:` records a hostname on the *new* snapshot - a single value, since this is metadata being written, not a filter, unlike every `hosts:` elsewhere in this API. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#backup-instance_method) for the full list of options (filtering, retention, timing, and more).
265
270
 
266
271
  Returns a `BackupOutcome`: `#summary` (aliased `#report`; a `BackupSummary`, Restic's own summary fields plus `#backup_start`/`#backup_end` parsed into `Time`), `#errors` (lazy Enumerable of `BackupError` - empty unless some files were skipped, e.g. permission errors; a full failure raises before an outcome exists at all, see ["Error handling"](#error-handling) below), `#command_output` (the `source: [:stdin_from_command, ...]` subprocess's own stderr, de-prefixed, if any).
267
272
 
@@ -291,7 +296,7 @@ outcome.summary["snapshot_id"]
291
296
  repo.restore(snapshot_id: "latest", target: "/tmp/restore")
292
297
  ```
293
298
 
294
- `snapshot_id:` accepts `"latest"` or a real ID; `target:` is the destination directory. `delete:` removes files in `target:` not present in the snapshot - worth knowing about since it's the one option here that can destroy data outside the snapshot itself. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#restore-instance_method) for the full list of options (filtering, `overwrite:` behavior, and more).
299
+ `snapshot_id:` accepts `"latest"` or a real ID; `target:` is the destination directory. `delete:` removes files in `target:` not present in the snapshot - the one option here that can destroy data outside the snapshot itself. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#restore-instance_method) for the full list of options (filtering, `overwrite:` behavior, and more).
295
300
 
296
301
  Returns a `RestoreOutcome`: `#summary` (aliased `#report`; Restic's own summary fields as a plain `Hash`) - restore has no exit code 3, so any item-level failure is a hard error that raises before an outcome exists.
297
302
 
@@ -315,7 +320,7 @@ repo.dump(snapshot_id: "latest", file: "/var/www", target: "/tmp/www.tar")
315
320
  repo.dump(snapshot_id: "latest", file: "/var/www", target: "/tmp/www.zip", archive: "zip")
316
321
  ```
317
322
 
318
- Give at most one of `target:` or a block. Without either, returns a `Yobi::IOHandle` instead. `hosts:`/`paths:`/`tags:` filters are also available when `snapshot_id:` is `"latest"` - see the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#dump-instance_method) for the full list of options.
323
+ Give at most one of `target:` or a block. Without either, returns a `Yobi::IOHandle` instead. `hosts:`/`paths:`/`tags:` filters are also available when `snapshot_id:` is `"latest"` - see the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#dump-instance_method) for the full list of options.
319
324
 
320
325
  ```ruby
321
326
  handle = repo.dump(snapshot_id: "latest", file: "/var/www")
@@ -335,7 +340,7 @@ outcome.statistics.changed_files
335
340
  outcome.changes.each { |change| puts "#{change.modifier} #{change.path}" }
336
341
  ```
337
342
 
338
- `metadata: true` also reports metadata-only changes (permissions, timestamps) alongside content changes. `change.modifier` is Restic's own concatenation of single-character codes: `+` added, `-` removed, `U` metadata updated, `M` content modified, `T` type changed, `?` bitrot detected. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#diff-instance_method) for the full list of options.
343
+ `metadata: true` also reports metadata-only changes (permissions, timestamps) alongside content changes. `change.modifier` is Restic's own concatenation of single-character codes: `+` added, `-` removed, `U` metadata updated, `M` content modified, `T` type changed, `?` bitrot detected. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#diff-instance_method) for the full list of options.
339
344
 
340
345
  Returns a `DiffOutcome`: `#statistics` (aliased `#report`; a `DiffStatistics` with `#changed_files` plus `#added`/`#removed` `DiffStat` breakdowns) and lazy `#changes` (Enumerable of `DiffChange`, with predicates like `#modified?`/`#added?` alongside the raw `#modifier`).
341
346
 
@@ -349,7 +354,7 @@ repo.snapshots(tags: ["daily"], hosts: "web-1").each do |snapshot|
349
354
  end
350
355
  ```
351
356
 
352
- Returns an Enumerable of `Snapshot` (`#id`, `#short_id`, `#time`, `#host`, `#tags`, `#paths`, `#parent_id`, `#summary` - a `SnapshotSummary` with the same stats fields `#backup`'s own summary has). `group_by:`/`latest:` group and limit results, e.g. `latest: 1` per group for "the most recent backup of each host." See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#snapshots-instance_method) for the full list of options.
357
+ Returns an Enumerable of `Snapshot` (`#id`, `#short_id`, `#time`, `#host`, `#tags`, `#paths`, `#parent_id`, `#summary` - a `SnapshotSummary` with the same stats fields `#backup`'s own summary has). `group_by:`/`latest:` group and limit results, e.g. `latest: 1` per group for "the most recent backup of each host." See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#snapshots-instance_method) for the full list of options.
353
358
 
354
359
  ### `#tag`
355
360
 
@@ -357,7 +362,7 @@ Returns an Enumerable of `Snapshot` (`#id`, `#short_id`, `#time`, `#host`, `#tag
357
362
  repo.tag(snapshot_ids: snapshot.id, add: "verified")
358
363
  ```
359
364
 
360
- `add:`/`remove:`/`set:` (mutually exclusive with `add:`/`remove:` in Restic itself) modify tags on snapshots matched by `snapshot_ids:` (or by `hosts:`/`paths:`/`tags:` filters when no explicit IDs are given). Since tags are part of a snapshot's content-addressed identity, tagging produces a *new* snapshot ID for every snapshot touched. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#tag-instance_method) for the full list of options.
365
+ `add:`/`remove:`/`set:` (mutually exclusive with `add:`/`remove:` in Restic itself) modify tags on snapshots matched by `snapshot_ids:` (or by `hosts:`/`paths:`/`tags:` filters when no explicit IDs are given). Since tags are part of a snapshot's content-addressed identity, tagging produces a *new* snapshot ID for every snapshot touched. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#tag-instance_method) for the full list of options.
361
366
 
362
367
  Returns a `TagOutcome`: `#summary` (aliased `#report`; a `TagSummary` - just `#changed_snapshots`) and lazy `#changes` (Enumerable of `TagChange`, the old-ID/new-ID pairs).
363
368
 
@@ -367,7 +372,7 @@ Returns a `TagOutcome`: `#summary` (aliased `#report`; a `TagSummary` - just `#c
367
372
  repo.forget(keep_daily: 7, keep_weekly: 4, keep_monthly: 12, prune: true)
368
373
  ```
369
374
 
370
- Applies a retention policy, removing snapshots that don't match any `keep_*` rule (counts like `keep_daily:`, or durations like `keep_within:`). `prune: true` also reclaims the disk space the forgotten snapshots held (equivalent to a separate `#prune` call afterward). See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#forget-instance_method) for the full list of options.
375
+ Applies a retention policy, removing snapshots that don't match any `keep_*` rule (counts like `keep_daily:`, or durations like `keep_within:`). `prune: true` also reclaims the disk space the forgotten snapshots held (equivalent to a separate `#prune` call afterward). See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#forget-instance_method) for the full list of options.
371
376
 
372
377
  Returns an Enumerable of `ForgetGroup` (Restic evaluates the policy per group, by default grouped by host+paths). Each exposes `#keep`/`#remove` (Arrays of `Snapshot`) and `#reasons` (an Array of `KeepReason` explaining which rule kept each surviving snapshot, e.g. `"daily snapshot"`).
373
378
 
@@ -380,9 +385,9 @@ repo.find(patterns: "*.pem").each do |matches|
380
385
  end
381
386
  ```
382
387
 
383
- Searches for files/directories by name pattern across snapshots. `blob:`/`pack:`/`tree:` switch to matching object IDs instead, for low-level troubleshooting. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#find-instance_method) for the full list of options.
388
+ Searches for files/directories by name pattern across snapshots. `blob:`/`pack:`/`tree:` switch to matching object IDs instead, for low-level troubleshooting. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#find-instance_method) for the full list of options.
384
389
 
385
- Returns an Enumerable of `MatchesPerSnapshot` (Restic's own docs describe find's output as "organized by snapshot," not a flat list). Each has `#snapshot`/`#snapshot_id` (just the matched snapshot's ID as a String - unlike elsewhere in this API, Restic's own `find` output doesn't include the full record), `#hits`, and `#matches` (an Array of `FindMatch`, with the usual file metadata: `#path`, `#size`, `#mtime`, etc.).
390
+ Returns an Enumerable of `MatchesPerSnapshot`, one per snapshot with matches. Each has `#snapshot`/`#snapshot_id` (just the matched snapshot's ID as a String - unlike elsewhere in this API, Restic's own `find` output doesn't include the full record), `#hits`, and `#matches` (an Array of `FindMatch`, with the usual file metadata: `#path`, `#size`, `#mtime`, etc.).
386
391
 
387
392
  ### `#ls`
388
393
 
@@ -392,7 +397,7 @@ outcome.snapshot.short_id
392
397
  outcome.nodes.each { |node| puts node.path }
393
398
  ```
394
399
 
395
- Lists a snapshot's files/directories. `recursive:` descends into subdirectories. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#ls-instance_method) for the full list of options.
400
+ Lists a snapshot's files/directories. `recursive:` descends into subdirectories. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#ls-instance_method) for the full list of options.
396
401
 
397
402
  Returns an `LsOutcome`: `#snapshot` (the resolved `Snapshot`, useful to see what `"latest"` actually resolved to) and lazy `#nodes` (aliased `#entries`; Enumerable of `Node` - Restic's own term for a file/directory entry).
398
403
 
@@ -406,7 +411,7 @@ outcome.summary.num_errors
406
411
  outcome.errors.each { |error| puts error.message }
407
412
  ```
408
413
 
409
- Verifies repository integrity. `read_data:` also reads and verifies every pack file's actual contents, not just structure (slow, thorough); `read_data_subset:` does a partial version of the same (e.g. `"5%"`, or `"1/20"` for a fifth each day in rotation). See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#check-instance_method) for the full list of options.
414
+ Verifies repository integrity. `read_data:` also reads and verifies every pack file's actual contents, not just structure (slow, thorough); `read_data_subset:` does a partial version of the same (e.g. `"5%"`, or `"1/20"` for a fifth each day in rotation). See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#check-instance_method) for the full list of options.
410
415
 
411
416
  Returns a `CheckOutcome`: `#summary` (aliased `#report`; a `CheckSummary` covering the error count and what to run next - repair or prune) and `#errors` (lazy Enumerable of `CheckError`) for problems found.
412
417
 
@@ -416,7 +421,7 @@ Returns a `CheckOutcome`: `#summary` (aliased `#report`; a `CheckSummary` coveri
416
421
  repo.prune(max_unused: "5%")
417
422
  ```
418
423
 
419
- Removes data no longer referenced by any snapshot. `dry_run:` reports what would happen without doing it; `max_unused:` targets a maximum acceptable unused-space ratio after pruning. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#prune-instance_method) for the full list of options. Returns `true`.
424
+ Removes data no longer referenced by any snapshot. `dry_run:` reports what would happen without doing it; `max_unused:` targets a maximum acceptable unused-space ratio after pruning. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#prune-instance_method) for the full list of options. Returns `true`.
420
425
 
421
426
  ### `#repair_index`, `#repair_packs`, `#repair_snapshots`
422
427
 
@@ -426,7 +431,7 @@ repo.repair_packs(ids: damaged_pack_ids)
426
431
  repo.repair_snapshots(snapshot_ids: affected_ids, forget: true)
427
432
  ```
428
433
 
429
- `#repair_index` rebuilds the index from the pack files present (the modern successor to Restic's now-deprecated `rebuild-index`). `#repair_packs` extracts intact blobs from damaged pack files and drops the rest. Restic also writes a backup copy of each given pack (named `pack-<id>`) into the *calling process's own current working directory* first, with no flag to disable this; be aware of where your process runs from before calling it. `#repair_snapshots` regenerates snapshots with damaged content removed. This is real data loss for whatever gets removed, so prefer a fresh `#backup` where the source data is still available, and run `#repair_index` first since this depends on a correct index. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#repair_snapshots-instance_method) for the full list of options.
434
+ `#repair_index` rebuilds the index from the pack files present (the modern successor to Restic's now-deprecated `rebuild-index`). `#repair_packs` extracts intact blobs from damaged pack files and drops the rest. Restic also writes a backup copy of each given pack (named `pack-<id>`) into the *calling process's own current working directory* first, with no flag to disable this; be aware of where your process runs from before calling it. `#repair_snapshots` regenerates snapshots with damaged content removed - depends on a correct index, so run `#repair_index` first. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#repair_snapshots-instance_method) for the full list of options.
430
435
 
431
436
  ### `#recover`
432
437
 
@@ -434,7 +439,7 @@ repo.repair_snapshots(snapshot_ids: affected_ids, forget: true)
434
439
  repo.recover
435
440
  ```
436
441
 
437
- Builds a new snapshot from any data present in the repository but not referenced by an existing snapshot (e.g. after an accidental `#forget`). Call `#snapshots` afterward to see whether anything was actually recovered. Restic reports "nothing to do" as plain text with no structured way to tell the two cases apart.
442
+ Builds a new snapshot from any data present in the repository but not referenced by an existing snapshot (e.g. after an accidental `#forget`). Call `#snapshots` afterward to see whether anything was actually recovered.
438
443
 
439
444
  ### `#rewrite`
440
445
 
@@ -442,7 +447,7 @@ Builds a new snapshot from any data present in the repository but not referenced
442
447
  repo.rewrite(snapshot_ids: old.id, excludes: "*.log", new_time: Time.now.iso8601)
443
448
  ```
444
449
 
445
- Creates new snapshots from existing ones with filters applied or metadata changed. With no `snapshot_ids:`/`hosts:`/`tags:`/`paths:` given, rewrites every snapshot in the repository - Restic's own default, not something Yobi adds. `dry_run:` reports what would happen without doing it. `forget: false` (the default) tags the new snapshots `"rewrite"` and keeps the originals; `forget: true` removes the originals instead (their data isn't reclaimed until a later `#prune`). See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#rewrite-instance_method) for the full list of options.
450
+ Creates new snapshots from existing ones with filters applied or metadata changed. With no `snapshot_ids:`/`hosts:`/`tags:`/`paths:` given, rewrites every snapshot in the repository. `dry_run:` reports what would happen without doing it. `forget: false` (the default) tags the new snapshots `"rewrite"` and keeps the originals; `forget: true` removes the originals instead (their data isn't reclaimed until a later `#prune`). See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#rewrite-instance_method) for the full list of options.
446
451
 
447
452
  ### `#migrate`
448
453
 
@@ -467,7 +472,7 @@ repo.stats(mode: "raw-data")
467
472
  # => #<Yobi::RepositoryStats total_size=... total_file_count=... snapshots_count=...>
468
473
  ```
469
474
 
470
- Repository size/file-count statistics. `mode:` picks the counting strategy, as a String or Symbol: `"restore-size"`/`:restore_size` (default), `"files-by-contents"`/`:files_by_contents`, `"blobs-per-file"`/`:blobs_per_file`, `"raw-data"`/`:raw_data` (actual on-disk size, accounting for deduplication). Anything else raises `ArgumentError`. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#stats-instance_method) for more. Returns a `RepositoryStats` covering size, file/blob counts, and (for a repository using compression) how much space it's saving.
475
+ Repository size/file-count statistics. `mode:` picks the counting strategy, as a String or Symbol: `"restore-size"`/`:restore_size` (default), `"files-by-contents"`/`:files_by_contents`, `"blobs-per-file"`/`:blobs_per_file`, `"raw-data"`/`:raw_data` (actual on-disk size, accounting for deduplication). Anything else raises `ArgumentError`. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#stats-instance_method) for more. Returns a `RepositoryStats` covering size, file/blob counts, and (for a repository using compression) how much space it's saving.
471
476
 
472
477
  ## Key management
473
478
 
@@ -480,9 +485,9 @@ repo.key_remove(id: old_key.id)
480
485
 
481
486
  `#key_add`/`#key_list`/`#key_passwd`/`#key_remove` (aliased `#add_key`/`#keys`/`#change_password`/`#remove_key`) manage passwords. Every key here is a different way to unlock the same underlying master key, not a separate encryption key of its own (see ["Low-level (`cat`) and object listing"](#low-level-cat-and-object-listing) below's note on `#cat_masterkey_and_game_over_if_this_leaks`).
482
487
 
483
- `#key_add`/`#key_passwd` share the same kwargs: `new_password:` is required, accepting the same shapes as `#initialize`'s `password:` minus `[:command, ...]`: a literal String; a `[:file, "..."]` tuple, resolved natively by Restic; `:insecure_no_password`; or anything responding to `#call`. Restic itself only accepts a new password by file, unlike the repository's own `password:`, so a literal String or callable is written to a briefly-lived, 0600-permissioned tempfile by Yobi first. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#key_add-instance_method) for the full list of options.
488
+ `#key_add`/`#key_passwd` share the same kwargs: `new_password:` is required, accepting the same shapes as `#initialize`'s `password:` minus `[:command, ...]`: a literal String; a `[:file, "..."]` tuple, resolved natively by Restic; `:insecure_no_password`; or anything responding to `#call`. Restic itself only accepts a new password by file, unlike the repository's own `password:`, so a literal String or callable is written to a briefly-lived, 0600-permissioned tempfile by Yobi first. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#key_add-instance_method) for the full list of options.
484
489
 
485
- `#key_passwd` rotates the *password*, not the underlying master key itself (see the note on `#cat_masterkey_and_game_over_if_this_leaks` below if that distinction matters for your threat model). On success, it also updates this same `Repository` instance's own `password:` to match, so it keeps working against the repository right afterward:
490
+ `#key_passwd` rotates the *password*, not the underlying master key itself (see the note on `#cat_masterkey_and_game_over_if_this_leaks` below). On success, it also updates this same `Repository` instance's own `password:` to match, so it keeps working against the repository right afterward:
486
491
 
487
492
  ```ruby
488
493
  repo.key_passwd(new_password: "new-password-value")
@@ -503,7 +508,7 @@ repo.list(:packs) # => Array of pack IDs
503
508
 
504
509
  `#cat_snapshot`/`#cat_index`/`#cat_key`/`#cat_tree` return a repository object's raw stored record as a Hash, lower-level than `#snapshots`/`#key_list`, which wrap entries in `Snapshot`/`Key` instead. `#cat_snapshot`/`#cat_key`/`#cat_tree` each accept either a bare ID String or the corresponding wrapper object (`Snapshot`/`Key`/`Snapshot`) directly; `#cat_index` only takes a bare ID String, since index entries have no wrapper class of their own. `#cat_pack`/`#cat_blob` are raw-bytes commands with the same block/`IOHandle` shape as `#dump`. `#list(type)` enumerates every object ID of a given type (`:blobs`/`:packs`/`:index`/`:snapshots`/`:keys`/`:locks`) as plain strings. Restic ignores `--json` for this command, so these come back exactly as Restic prints them.
505
510
 
506
- `#cat_masterkey_and_game_over_if_this_leaks` returns the repository's actual encryption/MAC key material: not a redacted reference, the real thing. There is no operation that rotates this key; every password change made afterward protects nothing already backed up if this value ever leaks. Treat the return value with more care than you'd give `password:` itself: unlike a password, this can't be rotated away after a leak.
511
+ `#cat_masterkey_and_game_over_if_this_leaks` returns the repository's actual encryption/MAC key material: not a redacted reference, the real thing. There is no operation that rotates this key; a leak means every past snapshot in the repository stays decryptable forever, no password change fixes that.
507
512
 
508
513
  ## Across repositories
509
514
 
@@ -521,7 +526,7 @@ A shortcut for setting up a `#copy` destination: constructs a new `Repository` a
521
526
  mirror_repo.copy(from_repo: primary_repo, tags: "nightly")
522
527
  ```
523
528
 
524
- Replicates snapshots from another repository into this one. `from_repo:` accepts a plain URL String, a `[:file, "..."]` tuple reading the URL from a file, or a `Repository` instance. Given the latter, its own `#url`/`#password` are used automatically unless `from_password:` is given explicitly. Already-copied snapshots are skipped automatically on a repeat run. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#copy-instance_method) for the full list of options. Returns `true`.
529
+ Replicates snapshots from another repository into this one. `from_repo:` accepts a plain URL String, a `[:file, "..."]` tuple reading the URL from a file, or a `Repository` instance. Given the latter, its own `#url`/`#password` are used automatically unless `from_password:` is given explicitly. Already-copied snapshots are skipped automatically on a repeat run. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#copy-instance_method) for the full list of options. Returns `true`.
525
530
 
526
531
  ## Mounting
527
532
 
@@ -544,7 +549,7 @@ ensure
544
549
  end
545
550
  ```
546
551
 
547
- `#stop` is safe to call more than once, and `MountHandle#pid`/`MountHandle#stop` are safe even if something *else* already triggered the unmount externally (`kill -INT <pid>`, or the OS's own `umount`/`fusermount` directly). `hosts:`/`paths:`/`tags:` restrict which snapshots appear under the mount's own `snapshots/` directory. `ready_timeout:` (default 10 seconds) bounds how long `#mount` waits for Restic's own readiness message before raising `Yobi::MountTimeout`. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Repository#mount-instance_method) for the full list of options.
552
+ `#stop` is safe to call more than once, and `MountHandle#pid`/`MountHandle#stop` are safe even if something *else* already triggered the unmount externally (`kill -INT <pid>`, or the OS's own `umount`/`fusermount` directly). `hosts:`/`paths:`/`tags:` restrict which snapshots appear under the mount's own `snapshots/` directory. `ready_timeout:` (default 10 seconds) bounds how long `#mount` waits for Restic's own readiness message before raising `Yobi::MountTimeout`. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Repository#mount-instance_method) for the full list of options.
548
553
 
549
554
  ## Restic-level (not scoped to a repository)
550
555
 
@@ -554,11 +559,11 @@ restic.version.version # => "0.19.1"
554
559
  restic.cache(cleanup: true)
555
560
  ```
556
561
 
557
- `#version` and `#cache` are the two Restic subcommands that don't touch a repository at all, so they live on `Yobi::Restic` rather than `Repository`. `#version` returns a `Yobi::ResticVersion` (`#version`, `#go_version`, `#go_os`, `#go_arch`); if the installed binary is old enough to ignore `--json` for this command entirely, Yobi falls back to parsing its plain-text output for the version number instead of raising a JSON parse error. `#cache` lists (or, with `cleanup: true`, cleans) local cache directories, returning `true`. See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Restic#cache-instance_method) for its remaining options.
562
+ `#version` and `#cache` are the two Restic subcommands that don't touch a repository at all, so they live on `Yobi::Restic` rather than `Repository`. `#version` returns a `Yobi::ResticVersion` (`#version`, `#go_version`, `#go_os`, `#go_arch`); if the installed binary is old enough to ignore `--json` for this command entirely, Yobi falls back to parsing its plain-text output for the version number instead of raising a JSON parse error. `#cache` lists (or, with `cleanup: true`, cleans) local cache directories, returning `true`. See the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Restic#cache-instance_method) for its remaining options.
558
563
 
559
564
  ## Global `Restic` settings
560
565
 
561
- Settings that apply regardless of which repository is being operated on live on `Yobi::Restic`, not `Repository`:
566
+ `Yobi::Restic` holds settings that apply to every command, regardless of repository:
562
567
 
563
568
  ```ruby
564
569
  restic = Yobi::Restic.new(
@@ -568,14 +573,9 @@ restic = Yobi::Restic.new(
568
573
  )
569
574
  ```
570
575
 
571
- Two genuinely different kinds, both exposed as plain `attr_accessor`s:
572
-
573
- - **Env-var-backed** (e.g. `cache_dir:`, `compression:`, `pack_size:`): `Restic#env` computes the corresponding `RESTIC_*` vars fresh from the current accessor values on every call. Mutating one after construction is picked up by the very next command.
574
- - **CLI-flag-only, no env var equivalent** (e.g. `limit_upload:`, `no_lock:`, `options:` - Restic's own `--option`/`-o`, an escape hatch for backend-specific tuning like `s3.connections=10`).
576
+ Some map to `RESTIC_*` env vars (`cache_dir:`, `compression:`, `pack_size:`). Others are CLI flags with no env equivalent (`limit_upload:`, `no_lock:`, `options:`, etc.). `options:` maps to Restic's `-o` for backend tuning like `s3.connections=10`.
575
577
 
576
- See the [YARD docs](https://rubydoc.info/gems/yobi/Yobi/Restic) for the full list of settings.
577
-
578
- "Picked up by the very next command" assumes calls happen one at a time. If you mutate one of these accessors on a `Restic` shared across threads while another thread has a command already in flight on it, which value that in-flight command actually uses is a race with no ordering guarantee, though not a crash: each accessor read/write is still atomic, so you won't get a corrupted argv, just an unpredictable choice between the old and new value.
578
+ Every setting is an `attr_accessor`. Change one and the next command sees the new value. Full list in the [docs](https://rubydoc.info/gems/yobi/1.1.0/Yobi/Restic).
579
579
 
580
580
  ### Sharing a `Restic` across repositories
581
581
 
@@ -586,11 +586,50 @@ customer_a = Yobi::Repository.new(url: "s3:.../customer-a", password: "...", res
586
586
  customer_b = Yobi::Repository.new(url: "s3:.../customer-b", password: "...", restic: restic)
587
587
  ```
588
588
 
589
- Both repositories now use the same `limit_upload:` value, and changing `restic.limit_upload` later affects every repository built from it. This isn't a shared, pooled cap: Restic applies `--limit-upload` per invocation, so two backups running concurrently through this same `restic` are each independently capped at 5000, not splitting one combined 5000 between them. Useful when one process backs up many repositories under one operational policy, without repeating the same settings on every `Repository.new` call.
589
+ Both use the same settings. Changing `restic.limit_upload` later affects every repository built from it.
590
+
591
+ `--limit-upload` is per-invocation. Two backups running concurrently through the same `restic` are each capped at 5000. They don't share one 5000 budget.
592
+
593
+ ## Cancelling a running command
594
+
595
+ Call `Repository#with_cancellation(token)` before `#backup`, `#restore`, `#check`, `#prune`, `#forget`, or `#copy`, then `token.cancel!` from another thread to stop it:
596
+
597
+ ```ruby
598
+ token = Yobi::Cancellation.new
599
+
600
+ worker = Thread.new do
601
+ repo.with_cancellation(token).backup(source: "/Users/zia/Documents")
602
+ rescue Yobi::Cancelled
603
+ # the user stopped it; not a backup failure
604
+ end
605
+
606
+ token.cancel! # => true
607
+ worker.join
608
+ ```
609
+
610
+ `#cancel!` sends **SIGINT**, not SIGKILL - Restic removes its own repository lock on the way out, so no `#unlock` is needed. Override with `cancel!(signal: "TERM")` only if you have a reason to.
611
+
612
+ Restic exits with its own documented code 130 when killed by a signal; Yobi raises `Yobi::Cancelled` for that instead of `Yobi::ResticCommandFailed`.
613
+
614
+ Safe at any point, from any thread; `#cancel!` is idempotent, returning `true` the first time and `false` after. Cancelling before the command starts means Restic never spawns; `Yobi::Cancelled#before_start?` tells the two cases apart:
615
+
616
+ ```ruby
617
+ begin
618
+ repo.with_cancellation(token).backup(source: "/data")
619
+ rescue Yobi::Cancelled => error
620
+ error.before_start? # => true if Restic never ran
621
+ error.execution[:exit_code] # => nil in that case, otherwise the real exit code
622
+ end
623
+ ```
590
624
 
591
- ### Minimum-version enforcement, in detail
625
+ What an interrupted run leaves behind:
592
626
 
593
- `Yobi::Restic#run`/`#run_dump`/`#run_mount` all call `#ensure_minimum_version!` before executing a real command. It's memoized per `Restic` instance. The check runs once, not on every call, since the binary on disk isn't expected to change mid-process. See ["Minimum supported version"](#minimum-supported-version) above for what triggers it and why 0.17.1 specifically.
627
+ - `#backup`: no snapshot is written; data already uploaded is generally left *unreferenced* rather than reused by the next run, and orphaned packs are only reclaimed by a later `#prune`.
628
+ - `#restore`: the target directory may be left partially written (some files complete, others missing).
629
+ - `#check`: read-only, nothing is written; safe to cancel at any point.
630
+ - `#prune`: Restic's repacking is designed to be crash-safe, but repacking done so far may not be reused (the next `#prune` can end up starting over).
631
+ - `#forget`: some snapshots may already be removed while the rest are intact, re-run to finish.
632
+ - `#copy`: snapshots already copied should stay in the destination and be skipped on a re-run.
594
633
 
595
634
  ## Redaction
596
635
 
@@ -601,7 +640,7 @@ repo.inspect
601
640
  # => #<Yobi::Repository url="s3:..." password="[FILTERED]" backend_credentials={"AWS_ACCESS_KEY_ID"=>"[FILTERED]", "AWS_SECRET_ACCESS_KEY"=>"[FILTERED]"}>
602
641
  ```
603
642
 
604
- A literal or command/file-sourced `password:` shows as `"[FILTERED]"`; anything callable shows as `"[RESOLVER]"` **without ever being called**. Printing a `Repository` never triggers a real secret-store lookup as a side effect. `:insecure_no_password` shows plainly, since it's an explicit "there is no secret" declaration, not a value that could leak anything. `backend_credentials:` keys not in Restic's own documented list of non-identity operational env vars (`Yobi::Restic::ALLOWED_ENV_VARS`) are filtered the same way; the allowed ones (`RESTIC_CACHE_DIR`, `AWS_DEFAULT_REGION`, and similar non-secret operational vars) stay visible.
643
+ A literal or command/file-sourced `password:` shows as `"[FILTERED]"`; anything callable shows as `"[RESOLVER]"` **without ever being called**. `:insecure_no_password` shows plainly, since it's an explicit "there is no secret" declaration, not a value that could leak anything. `backend_credentials:` keys not in Restic's own documented list of non-identity operational env vars (`Yobi::Restic::ALLOWED_ENV_VARS`) are filtered the same way; the allowed ones (`RESTIC_CACHE_DIR`, `AWS_DEFAULT_REGION`, and similar non-secret operational vars) stay visible.
605
644
 
606
645
  `#env` itself is never redacted. It's the method that actually builds what gets passed to `Process.spawn`, so it has to return the real values. Only `#inspect` (the thing a stray debug print might accidentally call) filters.
607
646
 
@@ -636,7 +675,7 @@ repo.forget(
636
675
  )
637
676
  ```
638
677
 
639
- A non-empty `outcome.errors` (exit code 3) means some files were skipped but the snapshot was still created (often fine to continue past, e.g. permission-denied on a handful of files); a full failure raises before `outcome` even exists via the usual `Yobi::ResticCommandFailed`/`Yobi::RepositoryLocked`/etc. hierarchy, so wrap the whole thing in the usual `rescue` if you want to alert on that separately (see ["Graceful lock-contention handling"](#graceful-lock-contention-handling) below).
678
+ A non-empty `outcome.errors` (exit code 3) means some files were skipped but the snapshot was still created - often fine to continue past. A full failure raises `Yobi::ResticCommandFailed`/`Yobi::RepositoryLocked`/etc. before `outcome` exists at all; handle those with a `rescue` around the block (see ["Graceful lock-contention handling"](#graceful-lock-contention-handling)).
640
679
 
641
680
  ### Restore workflows
642
681
 
@@ -704,7 +743,7 @@ password: -> {
704
743
  }
705
744
  ```
706
745
 
707
- If the resolver itself is slow or rate-limited (a network round-trip per Restic invocation adds up over many commands), add your own caching inside the lambda; how long the result stays valid is entirely your call.
746
+ If the resolver itself is slow or rate-limited (a network round-trip per Restic invocation adds up over many commands), add your own caching inside the lambda.
708
747
 
709
748
  ### Database dumps via `stdin_from_command`
710
749
 
@@ -717,7 +756,7 @@ repo.backup(
717
756
  )
718
757
  ```
719
758
 
720
- The third element (`"mydb.sql"`) names the resulting virtual file inside the snapshot. Omit it to fall back to Restic's own `"stdin"` default. If the command needs an argument containing a literal space (a password with a space in it, an unusual path), pass it as an Array of already-discrete arguments instead of a single String, since `Shellwords`-splitting a string would otherwise break it apart incorrectly. There's no shell involved either way (Restic execs the tokenized argv directly), but if any part of the String form is ever built from untrusted input rather than a hardcoded command like above, `Shellwords.split` will still tokenize whatever that input contains, letting it inject extra arguments into what gets executed. Use the Array form with untrusted values kept to their own discrete elements instead.
759
+ The third element (`"mydb.sql"`) names the resulting virtual file inside the snapshot. Omit it for Restic's own `"stdin"` default. Use the Array form (`["mysqldump", "-u", user, db]`) when any argument contains spaces, or when any part comes from untrusted input - the String form is tokenized with `Shellwords.split`, which will split spaces inside an argument and, on untrusted input, let it inject extra arguments.
721
760
 
722
761
  ```ruby
723
762
  repo.backup(source: [:stdin_from_command, ["mysqldump", "-u", "backup", "my app db"]])
@@ -772,7 +811,7 @@ Customer.find_each do |customer|
772
811
  end
773
812
  ```
774
813
 
775
- Running commands concurrently through a shared `Repository`/`Restic` is safe (see ["Global `Restic` settings"](#global-restic-settings) above for the one caveat, mutating `restic`'s tuning accessors from another thread while backups are in flight), so backing up many repositories doesn't have to run strictly one at a time. A `Thread.new` per customer would spawn an unbounded number of concurrent `restic` subprocesses, and Ruby silently drops an unhandled exception from a thread that's never joined, so a single customer's failure could vanish instead of getting logged. A small fixed-size worker pool avoids both:
814
+ To run backups concurrently, use a bounded worker pool. It caps how many `restic` subprocesses run at once and catches per-customer errors that would otherwise disappear with the thread:
776
815
 
777
816
  ```ruby
778
817
  queue = Queue.new
@@ -836,7 +875,7 @@ end
836
875
  with_retry { repo.backup(source: source_path) }
837
876
  ```
838
877
 
839
- For a one-off manual fix instead of retrying, `repo.unlock` removes stale locks left by a process that crashed without cleaning up after itself, but don't call it reflexively inside a retry loop that might be racing a *legitimately still-running* second process; that would double-run the backup.
878
+ For a one-off manual fix, `repo.unlock` removes stale locks left by a process that crashed without cleaning up. Don't put it inside the retry loop above - a lock might belong to a live second process, not a dead one.
840
879
 
841
880
  ## Non-goals
842
881
 
@@ -855,7 +894,7 @@ After checking out the repo, run `bin/setup` to install dependencies.
855
894
  - `bin/test`: both, in parallel.
856
895
  - `bin/standardrb`: lint.
857
896
  - `bin/console`: an interactive prompt with the gem loaded.
858
- - `bin/docs`: serves YARD docs at http://localhost:8808, reparsing on every request.
897
+ - `bin/docs`: regenerates RDoc into `./doc` and opens the index in the default browser.
859
898
 
860
899
  See `MAINTAINERS.md` for what to keep in sync as the API changes, and the release checklist.
861
900
 
@@ -1,17 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yobi
4
- # Builds a Restic command's argv incrementally. Not an Array subclass:
5
- # only the methods below are available; {#to_a} hands back the plain
6
- # Array once building is done.
7
- #
8
- # @private
9
- class ArgvBuilder
10
- # Maps a flag name Symbol to its "--dashed-string" form, e.g.
11
- # `:read_data_subset` to `"--read-data-subset"`.
4
+ class ArgvBuilder # :nodoc:
12
5
  FLAGS = Hash.new { |flags, name| flags[name] = "--#{name.to_s.tr("_", "-")}" }
13
-
14
- # Maps a short flag name Symbol to its "-name" form, e.g. `:vv` to `"-vv"`.
15
6
  SHORT_FLAGS = Hash.new { |flags, name| flags[name] = "-#{name}" }
16
7
 
17
8
  def initialize
@@ -19,21 +10,14 @@ module Yobi
19
10
  @end_of_options_called = false
20
11
  end
21
12
 
22
- # @return [Array<String>] the argv built so far
23
13
  def to_a
24
14
  @argv
25
15
  end
26
16
 
27
- # @return [String]
28
17
  def inspect
29
18
  "#<#{self.class} #{@argv.inspect}>"
30
19
  end
31
20
 
32
- # Appends one or more bare positional values. `nil` is dropped; an
33
- # Enumerable is flattened in; everything else is converted with `#to_s`.
34
- #
35
- # @param values [Array<Object>]
36
- # @return [self]
37
21
  def append(*values)
38
22
  values.each do |v|
39
23
  case v
@@ -49,41 +33,23 @@ module Yobi
49
33
  self
50
34
  end
51
35
 
52
- # Appends a flag, plus its value when one is given.
53
- #
54
- # @param name [Symbol] flag name, looked up in {FLAGS}
55
- # @param value [Object, nil] the flag's value; omit for a boolean flag
56
- # @return [self]
57
36
  def flag(name, value = nil)
58
37
  @argv << FLAGS[name]
59
38
  @argv << value.to_s unless value.nil?
60
39
  self
61
40
  end
62
41
 
63
- # Appends a repeatable flag once per value, e.g. `--host a --host b`.
64
- #
65
- # @param name [Symbol] flag name, looked up in {FLAGS}
66
- # @param values [Array<Object>, Object, nil]
67
- # @return [self]
68
42
  def repeat_flag(name, values)
69
43
  name = FLAGS[name]
70
44
  array_of_strings(values).each { |value| @argv << name << value }
71
45
  self
72
46
  end
73
47
 
74
- # Appends a short flag, e.g. `short_flag(:vv)` for `-vv`.
75
- #
76
- # @param name [Symbol] flag name, looked up in {SHORT_FLAGS}
77
- # @return [self]
78
48
  def short_flag(name)
79
49
  @argv << SHORT_FLAGS[name]
80
50
  self
81
51
  end
82
52
 
83
- # Appends Restic's `--` end-of-options marker.
84
- #
85
- # @return [self]
86
- # @raise [RuntimeError] if called more than once on the same builder
87
53
  def end_of_options
88
54
  raise "end_of_options already called - Restic only honors the first \"--\"" if @end_of_options_called
89
55
 
@@ -0,0 +1,48 @@
1
+ module Yobi
2
+ # Returned by Repository#with_cancellation. Exposes only the six
3
+ # long-running methods a Yobi::Cancellation token can stop; every other
4
+ # Repository method (#snapshots, #mount, #init, ...) is unreachable here.
5
+ #
6
+ # +token+ reaches the repository via a thread-local, set only for the
7
+ # duration of each call and only on the calling thread.
8
+ class CancellableProxy
9
+ def initialize(repository, token)
10
+ @repository = repository
11
+ @token = token
12
+ end
13
+
14
+ def backup(...)
15
+ with_token { @repository.backup(...) }
16
+ end
17
+
18
+ def restore(...)
19
+ with_token { @repository.restore(...) }
20
+ end
21
+
22
+ def check(...)
23
+ with_token { @repository.check(...) }
24
+ end
25
+
26
+ def prune(...)
27
+ with_token { @repository.prune(...) }
28
+ end
29
+
30
+ def forget(...)
31
+ with_token { @repository.forget(...) }
32
+ end
33
+
34
+ def copy(...)
35
+ with_token { @repository.copy(...) }
36
+ end
37
+
38
+ private
39
+
40
+ def with_token
41
+ previous = Thread.current[:yobi_cancellation]
42
+ Thread.current[:yobi_cancellation] = @token
43
+ yield
44
+ ensure
45
+ Thread.current[:yobi_cancellation] = previous
46
+ end
47
+ end
48
+ end