yobi 0.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.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "parallel_tests/tasks"
9
+
10
+ require "standard/rake"
11
+
12
+ task default: %i[spec standard]
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
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"`.
12
+ 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
+ SHORT_FLAGS = Hash.new { |flags, name| flags[name] = "-#{name}" }
16
+
17
+ def initialize
18
+ @argv = []
19
+ @end_of_options_called = false
20
+ end
21
+
22
+ # @return [Array<String>] the argv built so far
23
+ def to_a
24
+ @argv
25
+ end
26
+
27
+ # Appends one or more bare positional values. `nil` is dropped; an
28
+ # Enumerable is flattened in; everything else is converted with `#to_s`.
29
+ #
30
+ # @param values [Array<Object>]
31
+ # @return [self]
32
+ def append(*values)
33
+ values.each do |v|
34
+ case v
35
+ in nil
36
+ in String
37
+ @argv << v
38
+ in Enumerable
39
+ append(*v)
40
+ else
41
+ @argv << v.to_s
42
+ end
43
+ end
44
+ self
45
+ end
46
+
47
+ # Appends a flag, plus its value when one is given.
48
+ #
49
+ # @param name [Symbol] flag name, looked up in {FLAGS}
50
+ # @param value [Object, nil] the flag's value; omit for a boolean flag
51
+ # @return [self]
52
+ def flag(name, value = nil)
53
+ @argv << FLAGS[name]
54
+ @argv << value.to_s unless value.nil?
55
+ self
56
+ end
57
+
58
+ # Appends a repeatable flag once per value, e.g. `--host a --host b`.
59
+ #
60
+ # @param name [Symbol] flag name, looked up in {FLAGS}
61
+ # @param values [Array<Object>, Object, nil]
62
+ # @return [self]
63
+ def repeat_flag(name, values)
64
+ name = FLAGS[name]
65
+ array_of_strings(values).each { |value| @argv << name << value }
66
+ self
67
+ end
68
+
69
+ # Appends a short flag, e.g. `short_flag(:vv)` for `-vv`.
70
+ #
71
+ # @param name [Symbol] flag name, looked up in {SHORT_FLAGS}
72
+ # @return [self]
73
+ def short_flag(name)
74
+ @argv << SHORT_FLAGS[name]
75
+ self
76
+ end
77
+
78
+ # Appends Restic's `--` end-of-options marker.
79
+ #
80
+ # @return [self]
81
+ # @raise [RuntimeError] if called more than once on the same builder
82
+ def end_of_options
83
+ raise "end_of_options already called - Restic only honors the first \"--\"" if @end_of_options_called
84
+
85
+ @end_of_options_called = true
86
+ @argv << "--"
87
+ self
88
+ end
89
+
90
+ private
91
+
92
+ def array_of_strings(value)
93
+ if value.nil?
94
+ []
95
+ elsif value.respond_to?(:to_ary)
96
+ value.to_ary.map(&:to_s)
97
+ else
98
+ [value.to_s]
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "delegate"
5
+
6
+ module Yobi
7
+ # Base class for all errors raised by Yobi.
8
+ class Error < StandardError; end
9
+
10
+ # The `exit_error` message from a fatal Restic run, if one was printed.
11
+ # https://restic.readthedocs.io/en/stable/075_scripting.html#exit-errors
12
+ class ExitError < SimpleDelegator
13
+ # Matches a JSON line with `"message_type": "exit_error"`.
14
+ LINE_PATTERN = /"message_type"\s*:\s*"exit_error"/
15
+
16
+ # @param output [Yobi::ResticOutput]
17
+ # @return [Yobi::ExitError, nil] `nil` if no `exit_error` line is present
18
+ def self.from_output(output)
19
+ line = output.each_line.find { |candidate| LINE_PATTERN.match?(candidate) }
20
+ new(JSON.parse(line)) if line
21
+ end
22
+
23
+ # @return [String, nil]
24
+ def code
25
+ self["code"]
26
+ end
27
+
28
+ # @return [String, nil]
29
+ def message
30
+ self["message"]
31
+ end
32
+ end
33
+
34
+ # Raised when the installed Restic binary is older than
35
+ # {Yobi::Restic::MINIMUM_VERSION}.
36
+ class UnsupportedResticVersion < Error
37
+ # @return [String]
38
+ attr_reader :installed_version, :minimum_version
39
+
40
+ # @param installed_version [String]
41
+ # @param minimum_version [String]
42
+ def initialize(installed_version:, minimum_version:)
43
+ @installed_version = installed_version
44
+ @minimum_version = minimum_version
45
+ super("Restic #{installed_version} does not meet the minimum supported version #{minimum_version}")
46
+ end
47
+ end
48
+
49
+ # Raised when the Restic binary itself can't be found or executed.
50
+ class ResticNotFound < Error
51
+ # @return [String]
52
+ attr_reader :restic_path
53
+ # @return [Array<String>]
54
+ attr_reader :argv
55
+
56
+ # @param restic_path [String]
57
+ # @param argv [Array<String>]
58
+ def initialize(restic_path:, argv:)
59
+ @restic_path = restic_path
60
+ @argv = argv
61
+ super("Restic binary not found: #{restic_path.inspect} (tried to run #{argv.inspect})")
62
+ end
63
+ end
64
+
65
+ # Raised when Restic exits with a failure code. Base class for the
66
+ # specific typed errors below.
67
+ class ResticExecutionError < Error
68
+ # @return [Hash] the raw `{exit_code:, output:, argv:}` execution result
69
+ attr_reader :execution
70
+ # @return [Yobi::ExitError, nil]
71
+ attr_reader :exit_error
72
+
73
+ # @param execution [Hash]
74
+ def initialize(execution)
75
+ @execution = execution
76
+ @exit_error = Yobi::ExitError.from_output(execution[:output])
77
+ super(exit_error&.message || "Restic exited with status #{execution[:exit_code]}: #{execution[:output]}")
78
+ end
79
+ end
80
+
81
+ # Raised when the repository does not exist (Restic exit code 10).
82
+ class RepositoryNotFound < ResticExecutionError; end
83
+
84
+ # Raised when the repository is locked by another process (Restic exit code 11).
85
+ class RepositoryLocked < ResticExecutionError; end
86
+
87
+ # Raised when the repository password is incorrect (Restic exit code 12).
88
+ class AuthenticationFailed < ResticExecutionError; end
89
+
90
+ # Raised when Restic exits with a failure code not otherwise classified above.
91
+ class ResticCommandFailed < ResticExecutionError; end
92
+
93
+ # Raised by {Yobi::Repository#mount} when Restic doesn't report itself
94
+ # ready within `ready_timeout:` seconds.
95
+ class MountTimeout < Error
96
+ # @return [Array<String>]
97
+ attr_reader :argv
98
+ # @return [Numeric]
99
+ attr_reader :timeout
100
+
101
+ # @param argv [Array<String>]
102
+ # @param timeout [Numeric]
103
+ def initialize(argv:, timeout:)
104
+ @argv = argv
105
+ @timeout = timeout
106
+ super("Restic mount didn't report readiness within #{timeout}s (tried to run #{argv.inspect})")
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yobi
4
+ # A live Restic process streaming raw bytes to its own stdout, returned by
5
+ # {Yobi::Restic#run_dump} when no block is given. Satisfies Rack's Body
6
+ # contract (`#each`, `#close`).
7
+ #
8
+ # Unlike the block form, nothing closes this automatically. Call {#close}
9
+ # yourself once done reading, in an `ensure`, so it still runs if reading
10
+ # raises:
11
+ #
12
+ # @example
13
+ # handle = repo.dump(snapshot_id: "latest", file: "/etc/hosts")
14
+ # begin
15
+ # IO.copy_stream(handle.io, "/tmp/hosts")
16
+ # ensure
17
+ # handle.close
18
+ # end
19
+ class IOHandle
20
+ # @return [IO] the readable end of the pipe Restic's stdout is wired to
21
+ attr_reader :io
22
+ # @return [Integer] the Restic process's pid
23
+ attr_reader :pid
24
+
25
+ # @param io [IO]
26
+ # @param pid [Integer]
27
+ # @param output_file [File]
28
+ # @param argv [Array<String>]
29
+ def initialize(io, pid:, output_file:, argv:)
30
+ @io = io
31
+ @pid = pid
32
+ @output_file = output_file
33
+ @argv = argv
34
+ @closed = false
35
+ end
36
+
37
+ # @return [Boolean]
38
+ def closed?
39
+ @closed
40
+ end
41
+
42
+ # Reaps the Restic process and raises based on its exit code. Safe to
43
+ # call more than once.
44
+ #
45
+ # @return [void]
46
+ def close
47
+ return if @closed
48
+
49
+ @closed = true
50
+ @io.close unless @io.closed?
51
+ _, status = Process.wait2(@pid)
52
+ Restic.dispatch(exit_code: status.exitstatus, output: Yobi::ResticOutput.new(@output_file), argv: @argv)
53
+ end
54
+
55
+ # Yields binary-safe chunks of {#io} until EOF, then calls {#close}.
56
+ #
57
+ # @yieldparam chunk [String]
58
+ # @return [void]
59
+ def each
60
+ loop do
61
+ yield @io.readpartial(64 * 1024)
62
+ rescue EOFError
63
+ break
64
+ end
65
+ close
66
+ end
67
+ end
68
+ end
data/lib/yobi/mount.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yobi
4
+ # A live Restic mount process, returned by {Yobi::Restic#run_mount} once
5
+ # Restic has reported itself ready. The mounted filesystem itself is
6
+ # browsed with ordinary file I/O at {#mountpoint}; this object only
7
+ # manages the Restic process's lifetime.
8
+ class Mount
9
+ # @return [String] the path the repository is mounted at
10
+ attr_reader :mountpoint
11
+
12
+ # @param wait_thr [Process::Waiter] as returned by `Open3.popen2e`
13
+ # @param mountpoint [String]
14
+ # @param output [IO]
15
+ # @param output_file [File]
16
+ # @param argv [Array<String>]
17
+ def initialize(wait_thr:, mountpoint:, output:, output_file:, argv:)
18
+ @wait_thr = wait_thr
19
+ @mountpoint = mountpoint
20
+ @output = output
21
+ @output_file = output_file
22
+ @argv = argv
23
+ @stopped = false
24
+ end
25
+
26
+ # @return [Integer] the Restic process's pid
27
+ def pid
28
+ @wait_thr.pid
29
+ end
30
+
31
+ # @return [Boolean]
32
+ def stopped?
33
+ @stopped
34
+ end
35
+
36
+ # Sends the Restic process SIGINT, waits for it to unmount and exit,
37
+ # then raises based on its exit code. Safe to call more than once, and
38
+ # safe to call after the mount has already been stopped externally
39
+ # (e.g. via `kill -INT` or the OS's own `umount`/`fusermount`).
40
+ #
41
+ # @return [void]
42
+ def stop
43
+ return if @stopped
44
+
45
+ @stopped = true
46
+
47
+ begin
48
+ Process.kill("INT", pid)
49
+ rescue Errno::ESRCH
50
+ nil
51
+ end
52
+
53
+ drain_remaining_output
54
+ status = @wait_thr.value
55
+ Restic.dispatch(exit_code: status.exitstatus, output: Yobi::ResticOutput.new(@output_file), argv: @argv)
56
+ end
57
+
58
+ private
59
+
60
+ def drain_remaining_output
61
+ @output.each_line { |line| @output_file.write(line) }
62
+ rescue IOError, Errno::EBADF
63
+ nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "delegate"
6
+ require "shellwords"
7
+
8
+ module Yobi
9
+ class Repository
10
+ # `restic backup`: creates a new snapshot from a source path.
11
+ #
12
+ # @param source [String, Array] a path to back up, or a
13
+ # `[:stdin_from_command, command]`/`[:stdin_from_command, command, filename]`
14
+ # tuple. Restic spawns and executes `command` itself (a String,
15
+ # tokenized with `Shellwords.split`, or an Array of already-discrete
16
+ # arguments), capturing its stdout as the backup content
17
+ # @param excludes [Array<String>, String] exclude files matching these glob patterns
18
+ # @param exclude_files [Array<String>, String] path(s) to file(s) listing exclude patterns, one per line
19
+ # @param exclude_if_present [Array<String>, String] skip a directory containing a marker file with this name (optionally `name:content`)
20
+ # @param exclude_larger_than [String, nil] skip files larger than this size, e.g. `"1G"`
21
+ # @param files_from [Array<String>, String] read the files/dirs to back up from a file, one per line
22
+ # @param files_from_raw [Array<String>, String] like `files_from`, NUL-separated
23
+ # @param files_from_verbatim [Array<String>, String] like `files_from`, paths taken literally with no glob expansion
24
+ # @param iexcludes [Array<String>, String] like `excludes`, case-insensitive
25
+ # @param iexclude_files [Array<String>, String] like `exclude_files`, case-insensitive
26
+ # @param tags [Array<String>, String] tags to attach to the new snapshot
27
+ # @param dry_run [Boolean] report what would happen without doing it
28
+ # @param exclude_caches [Boolean] skip directories containing a CACHEDIR.TAG marker
29
+ # @param exclude_cloud_files [Boolean] skip files not fully present on disk (e.g. OneDrive placeholders)
30
+ # @param force [Boolean] back up unchanged files instead of skipping them
31
+ # @param group_by [String, nil] grouping used to find the parent snapshot, e.g. `"host,paths"`
32
+ # @param host [String, nil] hostname to record on the new snapshot, instead of the OS hostname
33
+ # @param ignore_ctime [Boolean] relax change detection to ignore ctime
34
+ # @param ignore_inode [Boolean] relax change detection to ignore inode number
35
+ # @param no_scan [Boolean] skip the pre-backup scan (disables percentage progress)
36
+ # @param one_file_system [Boolean] don't cross filesystem boundaries
37
+ # @param parent [String, nil] snapshot ID to use as the parent instead of the latest one
38
+ # @param read_concurrency [Integer, nil] number of concurrent file reads
39
+ # @param skip_if_unchanged [Boolean] don't create a snapshot if nothing changed
40
+ # @param time [String, nil] timestamp to record instead of now
41
+ # @param verbose [Boolean] stream a {Yobi::BackupVerboseStatus} per file to the block
42
+ # @param with_atime [Boolean] also store files' access times
43
+ # @yieldparam message [Yobi::BackupStatus, Yobi::BackupError, Yobi::BackupVerboseStatus]
44
+ # @return [Yobi::BackupOutcome]
45
+ def backup(source:, excludes: [], exclude_files: [], exclude_if_present: [], exclude_larger_than: nil,
46
+ files_from: [], files_from_raw: [], files_from_verbatim: [], iexcludes: [], iexclude_files: [],
47
+ tags: [], dry_run: false, exclude_caches: false, exclude_cloud_files: false, force: false,
48
+ group_by: nil, host: nil, ignore_ctime: false, ignore_inode: false, no_scan: false,
49
+ one_file_system: false, parent: nil, read_concurrency: nil, skip_if_unchanged: false,
50
+ time: nil, verbose: false, with_atime: false, &block)
51
+ argv = build_argv("backup") do |a|
52
+ a.repeat_flag(:exclude, excludes)
53
+ a.repeat_flag(:exclude_file, exclude_files)
54
+ a.repeat_flag(:exclude_if_present, exclude_if_present)
55
+ a.flag(:exclude_larger_than, exclude_larger_than) unless exclude_larger_than.nil?
56
+ a.repeat_flag(:files_from, files_from)
57
+ a.repeat_flag(:files_from_raw, files_from_raw)
58
+ a.repeat_flag(:files_from_verbatim, files_from_verbatim)
59
+ a.repeat_flag(:iexclude, iexcludes)
60
+ a.repeat_flag(:iexclude_file, iexclude_files)
61
+ a.repeat_flag(:tag, tags)
62
+ a.flag(:dry_run) if dry_run
63
+ a.flag(:exclude_caches) if exclude_caches
64
+ a.flag(:exclude_cloud_files) if exclude_cloud_files
65
+ a.flag(:force) if force
66
+ a.flag(:group_by, group_by) unless group_by.nil?
67
+ a.flag(:host, host) unless host.nil?
68
+ a.flag(:ignore_ctime) if ignore_ctime
69
+ a.flag(:ignore_inode) if ignore_inode
70
+ a.flag(:no_scan) if no_scan
71
+ a.flag(:one_file_system) if one_file_system
72
+ a.flag(:parent, parent) unless parent.nil?
73
+ a.flag(:read_concurrency, read_concurrency) unless read_concurrency.nil?
74
+ a.flag(:skip_if_unchanged) if skip_if_unchanged
75
+ a.flag(:time, time) unless time.nil?
76
+ a.short_flag(:v) if verbose
77
+ a.flag(:with_atime) if with_atime
78
+ case source
79
+ in String => path
80
+ a.end_of_options.append(path)
81
+ in [:stdin_from_command, command]
82
+ a.flag(:stdin_from_command)
83
+ a.end_of_options.append(tokenize(command))
84
+ in [:stdin_from_command, command, String => filename]
85
+ a.flag(:stdin_from_command)
86
+ a.flag(:stdin_filename, filename)
87
+ a.end_of_options.append(tokenize(command))
88
+ end
89
+ end
90
+
91
+ execution = if block
92
+ run_restic(argv) { |raw| dispatch_backup_message(raw, &block) }
93
+ else
94
+ run_restic(argv)
95
+ end
96
+ BackupOutcome.new(execution[:exit_code], execution[:output])
97
+ end
98
+
99
+ private
100
+
101
+ def dispatch_backup_message(raw)
102
+ case raw["message_type"]
103
+ when "status"
104
+ yield Yobi::BackupStatus.new(raw)
105
+ when "error"
106
+ yield Yobi::BackupError.new(raw)
107
+ when "verbose_status"
108
+ yield Yobi::BackupVerboseStatus.new(raw)
109
+ end
110
+ end
111
+
112
+ def tokenize(command)
113
+ case command
114
+ in String => value
115
+ Shellwords.split(value)
116
+ in Array => values
117
+ values.map(&:to_s)
118
+ end
119
+ end
120
+ end
121
+
122
+ # The outcome of one {Yobi::Repository#backup} call.
123
+ class BackupOutcome < Struct.new(:exit_code, :output)
124
+ # Matches Restic's own log line format for stderr relayed from a
125
+ # `source: [:stdin_from_command, ...]` command's own subprocess.
126
+ COMMAND_OUTPUT_LINE_PATTERN = /\Asubprocess [^:]+: (.*)/
127
+
128
+ # @return [Boolean]
129
+ def success?
130
+ exit_code.zero?
131
+ end
132
+
133
+ # @return [Boolean] true if the backup completed with some files skipped
134
+ def partial?
135
+ exit_code == 3
136
+ end
137
+
138
+ # @return [Enumerable<Yobi::BackupError>]
139
+ def errors
140
+ @errors ||= BackupErrors.new(output)
141
+ end
142
+
143
+ # The `source: [:stdin_from_command, ...]` subprocess's own stderr, if any, de-prefixed.
144
+ #
145
+ # @return [Array<String>]
146
+ def command_output
147
+ @command_output ||= output.each_line.filter_map do |line|
148
+ match = COMMAND_OUTPUT_LINE_PATTERN.match(line)
149
+ match[1] if match
150
+ end
151
+ end
152
+
153
+ # @return [Hash] Restic's own `"summary"` fields, with `"backup_start"`/`"backup_end"` parsed into `Time`
154
+ def report
155
+ @report ||= begin
156
+ hash = summary_hash
157
+ hash.merge(
158
+ "backup_start" => (Time.parse(hash["backup_start"]) if hash["backup_start"]),
159
+ "backup_end" => (Time.parse(hash["backup_end"]) if hash["backup_end"])
160
+ )
161
+ end
162
+ end
163
+
164
+ private
165
+
166
+ def summary_hash
167
+ line = output.last_line
168
+ hash = line && JSON.parse(line)
169
+ if hash && hash["message_type"] == "summary"
170
+ hash
171
+ else
172
+ find_summary || {}
173
+ end
174
+ end
175
+
176
+ def find_summary
177
+ output.each_line do |line|
178
+ next if line.strip.empty?
179
+
180
+ hash = JSON.parse(line)
181
+ return hash if hash["message_type"] == "summary"
182
+ end
183
+
184
+ nil
185
+ end
186
+ end
187
+
188
+ # One `"status"` message from a live backup run.
189
+ # https://restic.readthedocs.io/en/stable/075_scripting.html#status
190
+ class BackupStatus < SimpleDelegator
191
+ # @return [Float, nil]
192
+ def percent_done
193
+ self["percent_done"]
194
+ end
195
+
196
+ # @return [Integer, nil]
197
+ def total_files
198
+ self["total_files"]
199
+ end
200
+
201
+ # @return [Integer, nil]
202
+ def files_done
203
+ self["files_done"]
204
+ end
205
+
206
+ # @return [Integer, nil]
207
+ def total_bytes
208
+ self["total_bytes"]
209
+ end
210
+
211
+ # @return [Integer, nil]
212
+ def bytes_done
213
+ self["bytes_done"]
214
+ end
215
+
216
+ # @return [Array<String>]
217
+ def current_files
218
+ self["current_files"] || []
219
+ end
220
+
221
+ # @return [Integer]
222
+ def error_count
223
+ self["error_count"] || 0
224
+ end
225
+ end
226
+
227
+ # One `"error"` message from a backup run.
228
+ # https://restic.readthedocs.io/en/stable/075_scripting.html#error
229
+ class BackupError < SimpleDelegator
230
+ # @return [String, nil]
231
+ def message
232
+ dig("error", "message")
233
+ end
234
+
235
+ # @return [String, nil]
236
+ def during
237
+ self["during"]
238
+ end
239
+
240
+ # @return [String, nil]
241
+ def item
242
+ self["item"]
243
+ end
244
+ end
245
+
246
+ # Enumerable over every {Yobi::BackupError} in a run.
247
+ class BackupErrors
248
+ include Enumerable
249
+
250
+ # @param output [Yobi::ResticOutput]
251
+ def initialize(output)
252
+ @output = output
253
+ end
254
+
255
+ # @yieldparam error [Yobi::BackupError]
256
+ # @return [Enumerator] if no block is given
257
+ def each
258
+ return enum_for(:each) unless block_given?
259
+
260
+ @output.index["error"].each do |offset|
261
+ line = @output.read_line_at(offset)
262
+ yield BackupError.new(JSON.parse(line))
263
+ end
264
+ end
265
+ end
266
+
267
+ # One `"verbose_status"` message from a backup run, one per file. Only
268
+ # emitted when `verbose: true` is passed to {Yobi::Repository#backup}.
269
+ class BackupVerboseStatus < SimpleDelegator
270
+ # @return [String]
271
+ def action
272
+ self["action"]
273
+ end
274
+
275
+ # @return [String]
276
+ def item
277
+ self["item"]
278
+ end
279
+
280
+ # @return [Integer]
281
+ def duration
282
+ self["duration"]
283
+ end
284
+
285
+ # @return [Integer]
286
+ def data_size
287
+ self["data_size"]
288
+ end
289
+
290
+ # @return [Integer]
291
+ def data_size_in_repo
292
+ self["data_size_in_repo"]
293
+ end
294
+
295
+ # @return [Integer]
296
+ def metadata_size
297
+ self["metadata_size"]
298
+ end
299
+
300
+ # @return [Integer]
301
+ def metadata_size_in_repo
302
+ self["metadata_size_in_repo"]
303
+ end
304
+
305
+ # @return [Integer]
306
+ def total_files
307
+ self["total_files"]
308
+ end
309
+ end
310
+ end