blockbuster 0.4.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rubocop.yml +4 -0
- data/.ruby-version +1 -0
- data/.travis.yml +8 -0
- data/CHANGELOG.md +22 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +196 -0
- data/Rakefile +19 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/blockbuster.gemspec +28 -0
- data/lib/blockbuster.rb +20 -0
- data/lib/blockbuster/comparator.rb +90 -0
- data/lib/blockbuster/concerns/extractor.rb +34 -0
- data/lib/blockbuster/concerns/file_helpers.rb +12 -0
- data/lib/blockbuster/concerns/output_helpers.rb +8 -0
- data/lib/blockbuster/concerns/packager.rb +40 -0
- data/lib/blockbuster/configuration.rb +98 -0
- data/lib/blockbuster/delta.rb +76 -0
- data/lib/blockbuster/extraction_list.rb +50 -0
- data/lib/blockbuster/manager.rb +63 -0
- data/lib/blockbuster/master.rb +25 -0
- data/lib/blockbuster/version.rb +3 -0
- metadata +171 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 0bdda5e73f8cf27127f1e0e256f9049ea96d0fd3
|
4
|
+
data.tar.gz: a5e0150870a52e9e4e559e3f7a151ab29d163cff
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a7c390e1556f8bb3a9f0c905c543b01fab2c938e6c6638b4d5a5ec33c307058c1ab07ad8d7ea3183875e62cd276dd8c26756efee4843dd1c1e431c1492b01918
|
7
|
+
data.tar.gz: 6d7a7349a31718c0a0680d762ba2a5844a34f3bdcd15523bdca57d91f504ac81a5972781a651753c72a0b79a1753bce50087a8cf35bb6941d4e23fc88bff16db
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.3.0
|
data/.travis.yml
ADDED
data/CHANGELOG.md
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
0.4.1
|
2
|
+
===
|
3
|
+
|
4
|
+
- Use target_path instead of file_name for output
|
5
|
+
|
6
|
+
0.4.0
|
7
|
+
===
|
8
|
+
|
9
|
+
- introduce Delta features (opt-in feature)
|
10
|
+
- manager accepts a configuration object
|
11
|
+
- packager broken up into extractor and packager
|
12
|
+
- master file represented by Master object
|
13
|
+
- delta files represented by Delta object
|
14
|
+
- delegate files to extract to ExtractionList object
|
15
|
+
- add a Comparator object as blockbuster's in-memory datastore
|
16
|
+
|
17
|
+
0.3.0
|
18
|
+
====
|
19
|
+
|
20
|
+
- configuration class introduced
|
21
|
+
- manager delegates to configuration
|
22
|
+
- Blockbuster allow a `configure` setup block
|
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# Contributor Code of Conduct
|
2
|
+
|
3
|
+
As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
|
4
|
+
|
5
|
+
We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion.
|
6
|
+
|
7
|
+
Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
|
8
|
+
|
9
|
+
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
|
10
|
+
|
11
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
|
12
|
+
|
13
|
+
This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2016 Lukas Eklund
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,196 @@
|
|
1
|
+
# Blockbuster
|
2
|
+
|
3
|
+
[![Build Status](https://travis-ci.org/fastly/blockbuster.svg?branch=master)](https://travis-ci.org/fastly/blockbuster)
|
4
|
+
|
5
|
+
Managing your VCR cassettes since 2016.
|
6
|
+
|
7
|
+
The task of this gem is to take all your VCR cassettes and package them into one `.tar.gz` file
|
8
|
+
for adding to git or other distributed version control system.
|
9
|
+
|
10
|
+
## Installation
|
11
|
+
|
12
|
+
Add this line to your application's Gemfile:
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
gem 'blockbuster'
|
16
|
+
```
|
17
|
+
|
18
|
+
And then execute:
|
19
|
+
|
20
|
+
$ bundle
|
21
|
+
|
22
|
+
Or install it yourself as:
|
23
|
+
|
24
|
+
$ gem install blockbuster
|
25
|
+
|
26
|
+
Optionally, ignore your cassettes in git and make sure to include the tar.gz file:
|
27
|
+
|
28
|
+
```
|
29
|
+
# .gitignore
|
30
|
+
|
31
|
+
test/cassettes
|
32
|
+
!test/vcr_cassettes.tar.gz
|
33
|
+
```
|
34
|
+
|
35
|
+
## Usage
|
36
|
+
|
37
|
+
#### Minitest example
|
38
|
+
|
39
|
+
Given a directory layout of:
|
40
|
+
|
41
|
+
```
|
42
|
+
-- test
|
43
|
+
|-- blockbuster_spec.rb
|
44
|
+
|-- cassettes
|
45
|
+
| |-- foo.yml
|
46
|
+
| `-- bar.yml
|
47
|
+
`-- test_helper.rb
|
48
|
+
```
|
49
|
+
|
50
|
+
In your `test_helper.rb` add
|
51
|
+
|
52
|
+
|
53
|
+
|
54
|
+
```
|
55
|
+
require 'blockbuster'
|
56
|
+
|
57
|
+
manager = Blockbuster::Manager.new do |c|
|
58
|
+
c.test_directory = File.dirname(__FILE__)
|
59
|
+
c.silent = false
|
60
|
+
end
|
61
|
+
|
62
|
+
# Alternatively you can pass Blockbuster::Manager.new a Blockbuster::Configuration object. But do not do both. The block will win if you attempt to do both. To be clear, passing a configuration as an argument AND additionally providing a block isn't destructive, it just has no purpose. Pick one or the other.
|
63
|
+
|
64
|
+
manager.rent
|
65
|
+
```
|
66
|
+
|
67
|
+
And then in an after run block
|
68
|
+
|
69
|
+
```
|
70
|
+
Minitest.after_run do
|
71
|
+
manager.drop_off
|
72
|
+
end
|
73
|
+
```
|
74
|
+
|
75
|
+
If there were changes/additions/deletions to your cassette files a new tar.gz cassette file will be created.
|
76
|
+
|
77
|
+
#### Blockbuster::Configuration
|
78
|
+
|
79
|
+
The configuration constructor takes the following options:
|
80
|
+
|
81
|
+
```
|
82
|
+
cassette_directory: String
|
83
|
+
Name of directory where cassette files are stored.
|
84
|
+
Will be stored under the test directory.
|
85
|
+
default: 'casssettes'
|
86
|
+
master_tar_file: String
|
87
|
+
name of gz cassettes file.
|
88
|
+
default: 'vcr_cassettes.tar.gz'
|
89
|
+
test_directory: String
|
90
|
+
path to test directory where cassete file and cassetes will be stored.
|
91
|
+
default: 'test'
|
92
|
+
silent: Boolean
|
93
|
+
Silence all output.
|
94
|
+
default: false
|
95
|
+
wipe_cassette_dir: Boolean
|
96
|
+
If true, will wipe the existing cassette directory when `rent` is called.
|
97
|
+
default: false
|
98
|
+
enable_deltas: Boolean (more on this below)
|
99
|
+
Toggle the Delta feature
|
100
|
+
default: false
|
101
|
+
delta_directory: String
|
102
|
+
Name of the directory to store deltas (relative to test_directory)
|
103
|
+
default: 'deltas'
|
104
|
+
current_delta_name: String
|
105
|
+
Field that names the current delta
|
106
|
+
default: 'current_delta.tar.gz'
|
107
|
+
```
|
108
|
+
|
109
|
+
These are all read-only attributes with the exception of `silent`. This is writeable so that one can suppress output
|
110
|
+
on setup but see output about new/changed cassettes upon `drop_off`.
|
111
|
+
|
112
|
+
There are 3 public methods
|
113
|
+
|
114
|
+
```
|
115
|
+
manager.rent
|
116
|
+
manager.setup
|
117
|
+
```
|
118
|
+
|
119
|
+
Extracts all cassettes from `test/vcr_cassettes.tar.gz` into `test/cassetes`
|
120
|
+
directory. To wipe the existing directory before extracting cassettes
|
121
|
+
initialize the manager with `wipe_cassette_dir: true`.
|
122
|
+
|
123
|
+
```
|
124
|
+
manager.rewind?
|
125
|
+
```
|
126
|
+
|
127
|
+
Compares the the files in `test/cassettes` to the files created during setup. Returns `true`
|
128
|
+
if there are any changes or additions. Returns `false` if they are identical.
|
129
|
+
|
130
|
+
```
|
131
|
+
manager.drop_off
|
132
|
+
manager.teardown
|
133
|
+
```
|
134
|
+
|
135
|
+
Packages cassete files into `test/vcr_cassettes.tar.gz` if `rewind?` returns true.
|
136
|
+
Can be called with `force: true` to force it to create the cassete file.
|
137
|
+
|
138
|
+
#### Recreating a cassette file
|
139
|
+
|
140
|
+
If you are using automatic re-recording of cassettes Blockbuster will see the changes and create a new package.
|
141
|
+
To skip the cassete extraction and use the existing local cassettes you can run your tests with `VCR_MODE=local`
|
142
|
+
|
143
|
+
```
|
144
|
+
VCR_MODE=local rake test
|
145
|
+
```
|
146
|
+
|
147
|
+
You can remove a single existing cassette and run in local mode and VCR will re-record that cassette and Blockbuster will
|
148
|
+
package a new cassettes file.
|
149
|
+
|
150
|
+
#### Removing or renaming a cassette file
|
151
|
+
|
152
|
+
If you rename a cassette or need to delete one from the archive you need to do the following:
|
153
|
+
|
154
|
+
* Run your test suite so that you have an up-to-date cassette directory
|
155
|
+
* Do the work to rename test/cassette etc
|
156
|
+
* Run tests (even that single test) with `VCR_MODE=local`
|
157
|
+
|
158
|
+
#### Re-record all cassettes
|
159
|
+
|
160
|
+
```
|
161
|
+
> rm -r test/cassettes
|
162
|
+
> rm test/vcr_cassettes.tar.gz
|
163
|
+
> rake test
|
164
|
+
```
|
165
|
+
|
166
|
+
### Delta feature (*Experimental*)
|
167
|
+
|
168
|
+
If you are working on a project that requires a lot of re-recording, or is in active development with HTTP interactions to different systems and multiple developers working on the project, the benefits of Blockbuster degrade quite quickly. Merge conflicts happen very frequently since all cassettes are stored in one file, and the only resolution is to re-record everything.
|
169
|
+
|
170
|
+
This is why deltas were built. The idea is inspired by Sphinx's delta index system. The idea is to add changes or creations to delta files, and not a master file. In the typical git branching workflow, this would work as follows:
|
171
|
+
|
172
|
+
- If no master file exists, one is generated the first time someone utilizes blockbuster.
|
173
|
+
- current_delta_name is set by dynamically retrieving the git branch name (This git-branch retrieval is the responsibility of the application to configure)
|
174
|
+
- as long as a master file exists, Blockbuster will only add changes to a new tarball.
|
175
|
+
- once you've switched to a new branch (presumably you've gotten your branch merged into master), Blockbuster stops writing to that delta, and only applies changes to a new delta based off the new branch name.
|
176
|
+
- The delta file names include a timestamp, based on when the file was packaged. This allows Blockbuster to use best-effort sorting to load in all delta files.
|
177
|
+
- Blockbuster maintains an in-memory datastore of files and their last checksums. To build this, Blockbuster extracts files from all available tarballs in a sorted order. The order is always Master first, and then delta files sorted by filename, which for all intents and purposes is based on time of creation (since the name includes a timestamp). This means that if more than one tarball contains the exact same file, the checksum in the datastore will come from the last file in the sort that contains it.
|
178
|
+
- This allows conflicts to become far less possible. Additionally, even if a conflict does occur, resolving the conflict becomes much easier, as the conflict will be isolated to the changes you are actively working on.
|
179
|
+
- Deletions aren't managed by deltas. This is because we want to maintain the principle of never touching any other tarball other than master or the the current delta. To actually delete a file, we'd have to remove it from any tarball it exists in. This isn't worth the advantage of the guarantee of leaving existing deltas alone. In the end, regenerating a Master file will resolve deleted files.
|
180
|
+
- Regenerating a new Master file is actually relatively simple. The only mechanism required to have Blockbuster do this automatically is to simply delete the existing Master file. It is additionally currently the responsibility of the application to remove deltas when regenerating a new master file.
|
181
|
+
|
182
|
+
## Development
|
183
|
+
|
184
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
185
|
+
|
186
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
187
|
+
|
188
|
+
## Contributing
|
189
|
+
|
190
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/blockbuster. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct.
|
191
|
+
|
192
|
+
|
193
|
+
## License
|
194
|
+
|
195
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
196
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'bundler/gem_tasks'
|
2
|
+
require 'rake/testtask'
|
3
|
+
require 'rubocop/rake_task'
|
4
|
+
|
5
|
+
Rake::TestTask.new(:test) do |t|
|
6
|
+
t.libs << 'spec'
|
7
|
+
t.libs << 'lib'
|
8
|
+
t.test_files = FileList['spec/**/*_spec.rb']
|
9
|
+
end
|
10
|
+
|
11
|
+
desc 'Run rubocop'
|
12
|
+
RuboCop::RakeTask.new do |task|
|
13
|
+
task.options = %w(--display-cop-names)
|
14
|
+
task.formatters = %w(fuubar)
|
15
|
+
task.fail_on_error = true
|
16
|
+
end
|
17
|
+
|
18
|
+
task spec: :test
|
19
|
+
task default: :test
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'bundler/setup'
|
4
|
+
require 'blockbuster'
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require 'pry'
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require 'pry'
|
14
|
+
Pry.start
|
data/bin/setup
ADDED
data/blockbuster.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'blockbuster/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'blockbuster'
|
8
|
+
spec.version = Blockbuster::VERSION
|
9
|
+
spec.authors = ['Lukas Eklund', 'Alexander Bergman', 'Hassan Shahid']
|
10
|
+
spec.email = ['leklund@fastly.com', 'alexander@fastly.com', 'hassan@fastly.com']
|
11
|
+
|
12
|
+
spec.summary = 'Packaging VCR cassettes for git since 2016'
|
13
|
+
spec.homepage = 'https://github.com/fastly/blockbuster'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
17
|
+
spec.bindir = 'exe'
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.add_development_dependency 'bundler', '~> 1.10'
|
22
|
+
spec.add_development_dependency 'bundler-audit'
|
23
|
+
spec.add_development_dependency 'minitest'
|
24
|
+
spec.add_development_dependency 'mocha'
|
25
|
+
spec.add_development_dependency 'pry'
|
26
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
27
|
+
spec.add_development_dependency 'rubocop', '~> 0.37'
|
28
|
+
end
|
data/lib/blockbuster.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'rubygems/package'
|
3
|
+
require 'zlib'
|
4
|
+
|
5
|
+
require 'blockbuster/configuration'
|
6
|
+
require 'blockbuster/concerns/file_helpers'
|
7
|
+
require 'blockbuster/concerns/output_helpers'
|
8
|
+
require 'blockbuster/concerns/extractor'
|
9
|
+
require 'blockbuster/concerns/packager'
|
10
|
+
require 'blockbuster/master'
|
11
|
+
require 'blockbuster/delta'
|
12
|
+
require 'blockbuster/comparator'
|
13
|
+
require 'blockbuster/extraction_list'
|
14
|
+
|
15
|
+
require 'blockbuster/manager'
|
16
|
+
require 'blockbuster/version'
|
17
|
+
|
18
|
+
# nodoc
|
19
|
+
module Blockbuster
|
20
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
module Blockbuster
|
2
|
+
# Data store for files, sources, states, and checksums
|
3
|
+
class Comparator
|
4
|
+
include Blockbuster::FileHelpers
|
5
|
+
include Blockbuster::OutputHelpers
|
6
|
+
|
7
|
+
CONTENT = 'content'.freeze
|
8
|
+
SOURCE = 'source'.freeze
|
9
|
+
|
10
|
+
attr_reader :configuration, :inventory, :edited, :current_delta_files, :deleted
|
11
|
+
|
12
|
+
def initialize(configuration)
|
13
|
+
@configuration = configuration
|
14
|
+
@inventory = {}
|
15
|
+
@edited = []
|
16
|
+
@current_delta_files = []
|
17
|
+
@deleted = []
|
18
|
+
end
|
19
|
+
|
20
|
+
def add(key, value, source)
|
21
|
+
inventory[key] = { CONTENT => value, SOURCE => source }
|
22
|
+
end
|
23
|
+
|
24
|
+
def delete(key)
|
25
|
+
inventory.delete(key)
|
26
|
+
end
|
27
|
+
|
28
|
+
def keys
|
29
|
+
inventory.keys
|
30
|
+
end
|
31
|
+
|
32
|
+
def present?
|
33
|
+
!keys.empty?
|
34
|
+
end
|
35
|
+
|
36
|
+
def edited?(file)
|
37
|
+
(edited + current_delta_files).include?(file)
|
38
|
+
end
|
39
|
+
|
40
|
+
def store_current_delta_files
|
41
|
+
inventory.each do |k, v|
|
42
|
+
scrubbed = Blockbuster::Delta.file_name_without_timestamp(v[SOURCE])
|
43
|
+
current_delta_files << k if scrubbed == configuration.current_delta_name
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def compare(key, new_digest)
|
48
|
+
digest = inventory[key]
|
49
|
+
|
50
|
+
if digest.nil?
|
51
|
+
silent_puts "New cassette: #{key}"
|
52
|
+
return true
|
53
|
+
elsif digest[CONTENT] != new_digest
|
54
|
+
silent_puts "Cassette changed: #{key}"
|
55
|
+
return true
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def rewind?(files) # rubocop:disable Metrics/AbcSize
|
60
|
+
base_files = []
|
61
|
+
|
62
|
+
files.each do |file|
|
63
|
+
next unless File.file?(file)
|
64
|
+
|
65
|
+
key = configuration.key_from_path(file)
|
66
|
+
base_files << key
|
67
|
+
|
68
|
+
edited << key if compare(key, file_digest(file))
|
69
|
+
end
|
70
|
+
|
71
|
+
@deleted = keys - base_files
|
72
|
+
|
73
|
+
return true if any_deleted?
|
74
|
+
|
75
|
+
!edited.empty?
|
76
|
+
end
|
77
|
+
|
78
|
+
def any_deleted?
|
79
|
+
if configuration.deltas_disabled? && !@deleted.empty?
|
80
|
+
silent_puts "Cassettes deleted: #{@deleted}"
|
81
|
+
return true
|
82
|
+
elsif configuration.deltas_enabled? && !(current_delta_files & @deleted).empty?
|
83
|
+
silent_puts "Cassettes deleted: #{@deleted}"
|
84
|
+
return true
|
85
|
+
end
|
86
|
+
|
87
|
+
false
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Blockbuster
|
2
|
+
# extracts files from gzipped tarballs
|
3
|
+
module Extractor
|
4
|
+
def extract_cassettes
|
5
|
+
return unless File.exist?(file_path)
|
6
|
+
File.open(file_path, 'rb') do |file|
|
7
|
+
Zlib::GzipReader.wrap(file) do |gz|
|
8
|
+
Gem::Package::TarReader.new(gz) do |tar|
|
9
|
+
tar.each do |entry|
|
10
|
+
untar_file(entry) if entry.file?
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def untar_file(entry)
|
18
|
+
contents = entry.read
|
19
|
+
@comparator.add(entry.full_name, Digest::MD5.hexdigest(contents), file_name)
|
20
|
+
|
21
|
+
save_to_disk(entry, contents) unless configuration.local_mode
|
22
|
+
end
|
23
|
+
|
24
|
+
def save_to_disk(entry, contents)
|
25
|
+
destination = File.join configuration.test_directory, entry.full_name
|
26
|
+
|
27
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
28
|
+
File.open(destination, 'wb') do |cass|
|
29
|
+
cass.write(contents)
|
30
|
+
end
|
31
|
+
File.chmod(entry.header.mode, destination)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Blockbuster
|
2
|
+
# pure ruby implmentation of tar gzip and diff
|
3
|
+
module Packager
|
4
|
+
def create_cassette_file
|
5
|
+
FileUtils.rm(file_path) if File.exist?(file_path)
|
6
|
+
File.open(target_path, 'wb') do |file|
|
7
|
+
Zlib::GzipWriter.wrap(file) do |gz|
|
8
|
+
Gem::Package::TarWriter.new(gz) do |tar|
|
9
|
+
configuration.cassette_files.each do |cass|
|
10
|
+
tar_file(tar, cass)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def tar_file(tar, file)
|
18
|
+
rel_path = configuration.key_from_path(file)
|
19
|
+
|
20
|
+
if configuration.deltas_enabled?
|
21
|
+
return unless @comparator.edited?(rel_path)
|
22
|
+
end
|
23
|
+
|
24
|
+
write_to_disk(tar, file)
|
25
|
+
end
|
26
|
+
|
27
|
+
def write_to_disk(tar, file)
|
28
|
+
mode = File.stat(file).mode
|
29
|
+
rel_path = configuration.key_from_path(file)
|
30
|
+
|
31
|
+
if File.directory?(file)
|
32
|
+
tar.mkdir rel_path, mode
|
33
|
+
else
|
34
|
+
tar.add_file_simple rel_path, mode, File.size(file) do |io|
|
35
|
+
File.open(file, 'rb') { |f| io.write f.read }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
module Blockbuster
|
2
|
+
# Manages blockbuster configuration
|
3
|
+
class Configuration
|
4
|
+
MASTER_TAR_FILE = 'vcr_cassettes'.freeze
|
5
|
+
CASSETTE_DIRECTORY = 'cassettes'.freeze
|
6
|
+
TEST_DIRECTORY = 'test'.freeze
|
7
|
+
WIPE_CASSETTE_DIR = false
|
8
|
+
LOCAL_MODE = 'local'.freeze
|
9
|
+
SILENT = false
|
10
|
+
ENABLE_DELTAS = false
|
11
|
+
DELTA_DIRECTORY = 'deltas'.freeze
|
12
|
+
CURRENT_DELTA_NAME = 'current_delta'.freeze
|
13
|
+
EXTENSION = '.tar.gz'.freeze
|
14
|
+
|
15
|
+
# @param cassette_directory [String] Name of directory cassette files are stored.
|
16
|
+
# Will be stored under the test directory. default: 'casssettes'
|
17
|
+
# @param master_tar_file [String] name of gz cassettes file. default: 'vcr_cassettes.tar.gz'
|
18
|
+
# @param test_directory [String] path to test directory where cassete file and cassetes will be stored.
|
19
|
+
# default: 'test'
|
20
|
+
# @param silent [Boolean] Silence all output. default: false
|
21
|
+
# @param enable_deltas [Boolean] Enables delta functionality. default: false
|
22
|
+
# @param delta_directory [String] Specifies directory for deltas. default: 'deltas'
|
23
|
+
# @param current_delta_name [String] Name of the current delta. default: 'current_delta.tar.gz'
|
24
|
+
attr_writer :cassette_directory, :master_tar_file, :local_mode, :test_directory, :wipe_cassette_dir, :silent, :enable_deltas, :delta_directory, :current_delta_name
|
25
|
+
|
26
|
+
def cassette_directory
|
27
|
+
@cassette_directory ||= CASSETTE_DIRECTORY
|
28
|
+
end
|
29
|
+
|
30
|
+
def master_tar_file
|
31
|
+
@master_tar_file ||= MASTER_TAR_FILE
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_directory
|
35
|
+
@test_directory ||= TEST_DIRECTORY
|
36
|
+
end
|
37
|
+
|
38
|
+
def silent
|
39
|
+
@silent ||= SILENT
|
40
|
+
end
|
41
|
+
|
42
|
+
alias silent? silent
|
43
|
+
|
44
|
+
def wipe_cassette_dir
|
45
|
+
@wipe_cassette_dir ||= WIPE_CASSETTE_DIR
|
46
|
+
end
|
47
|
+
|
48
|
+
def local_mode
|
49
|
+
@local_mode ||= ENV['VCR_MODE'] == LOCAL_MODE
|
50
|
+
end
|
51
|
+
|
52
|
+
def enable_deltas
|
53
|
+
@enable_deltas ||= ENABLE_DELTAS
|
54
|
+
end
|
55
|
+
|
56
|
+
alias deltas_enabled? enable_deltas
|
57
|
+
|
58
|
+
def deltas_disabled?
|
59
|
+
!deltas_enabled?
|
60
|
+
end
|
61
|
+
|
62
|
+
def delta_directory
|
63
|
+
@delta_directory ||= DELTA_DIRECTORY
|
64
|
+
end
|
65
|
+
|
66
|
+
def full_delta_directory
|
67
|
+
File.join(test_directory, delta_directory)
|
68
|
+
end
|
69
|
+
|
70
|
+
def current_delta_name
|
71
|
+
@current_delta_name ||= CURRENT_DELTA_NAME
|
72
|
+
@current_delta_name += EXTENSION unless @current_delta_name.include?(EXTENSION)
|
73
|
+
|
74
|
+
@current_delta_name
|
75
|
+
end
|
76
|
+
|
77
|
+
def key_from_path(file)
|
78
|
+
path_array = File.dirname(file).split('/')
|
79
|
+
idx = path_array.index(cassette_directory)
|
80
|
+
path_array[idx..-1].push(File.basename(file)).join('/')
|
81
|
+
end
|
82
|
+
|
83
|
+
def cassette_dir
|
84
|
+
File.join(test_directory, cassette_directory)
|
85
|
+
end
|
86
|
+
|
87
|
+
def cassette_files
|
88
|
+
Dir.glob("#{cassette_dir}/**/*")
|
89
|
+
end
|
90
|
+
|
91
|
+
def master_tar_file_path
|
92
|
+
name = File.join(test_directory, master_tar_file)
|
93
|
+
name += EXTENSION unless name.include?(EXTENSION)
|
94
|
+
|
95
|
+
name
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module Blockbuster
|
2
|
+
# Delta file objects
|
3
|
+
class Delta
|
4
|
+
include Blockbuster::Extractor
|
5
|
+
include Blockbuster::Packager
|
6
|
+
|
7
|
+
attr_reader :current, :file_name, :configuration
|
8
|
+
|
9
|
+
# nodoc
|
10
|
+
class NotEnabledError < StandardError
|
11
|
+
def message
|
12
|
+
'Deltas are not enabled. Please enable them via configuration to use them'
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
INITIALIZING_NUMBER = 101_010_101
|
17
|
+
|
18
|
+
def self.files(directory)
|
19
|
+
Dir.glob("#{directory}/*.tar.gz").sort.map { |file| File.basename(file) }
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.initialize_for_each(comparator, configuration)
|
23
|
+
setup_directory(configuration.full_delta_directory)
|
24
|
+
|
25
|
+
delta_files = files(configuration.full_delta_directory)
|
26
|
+
|
27
|
+
# If the current delta doesn't exist we want to add it
|
28
|
+
current_delta = configuration.current_delta_name
|
29
|
+
delta_files << "#{INITIALIZING_NUMBER}_#{current_delta}" unless delta_files.any? { |file| file_name_without_timestamp(file) == current_delta }
|
30
|
+
|
31
|
+
delta_files.map do |file|
|
32
|
+
new(file, comparator, configuration)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.setup_directory(directory)
|
37
|
+
return if Dir.exist?(directory)
|
38
|
+
|
39
|
+
FileUtils.mkdir_p(directory)
|
40
|
+
FileUtils.touch("#{directory}/.keep")
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.file_name_without_timestamp(file_name)
|
44
|
+
file_name.sub(/^\d+_/, '')
|
45
|
+
end
|
46
|
+
|
47
|
+
def initialize(file_name, comparator, configuration)
|
48
|
+
raise NotEnabledError if configuration.deltas_disabled?
|
49
|
+
|
50
|
+
@configuration = configuration
|
51
|
+
@comparator = comparator
|
52
|
+
@file_name = file_name
|
53
|
+
@current = true if file_name_without_timestamp == configuration.current_delta_name
|
54
|
+
end
|
55
|
+
|
56
|
+
def file_name_without_timestamp
|
57
|
+
self.class.file_name_without_timestamp(file_name)
|
58
|
+
end
|
59
|
+
|
60
|
+
def current
|
61
|
+
@current || false
|
62
|
+
end
|
63
|
+
|
64
|
+
alias current? current
|
65
|
+
|
66
|
+
def file_path
|
67
|
+
File.join(configuration.full_delta_directory, file_name)
|
68
|
+
end
|
69
|
+
|
70
|
+
def target_path
|
71
|
+
target = [Time.now.to_i, configuration.current_delta_name].join('_')
|
72
|
+
|
73
|
+
File.join(configuration.full_delta_directory, target)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Blockbuster
|
2
|
+
# generates an ordered collection of files to extract
|
3
|
+
class ExtractionList
|
4
|
+
attr_reader :files, :configuration
|
5
|
+
|
6
|
+
def initialize(comparator, configuration)
|
7
|
+
@configuration = configuration
|
8
|
+
@comparator = comparator
|
9
|
+
|
10
|
+
list = [master]
|
11
|
+
list << deltas if configuration.deltas_enabled?
|
12
|
+
|
13
|
+
@files = list.flatten
|
14
|
+
end
|
15
|
+
|
16
|
+
def current_delta
|
17
|
+
deltas.find(&:current?)
|
18
|
+
end
|
19
|
+
|
20
|
+
def deltas
|
21
|
+
@deltas ||= Delta.initialize_for_each(@comparator, configuration)
|
22
|
+
end
|
23
|
+
|
24
|
+
def extract_cassettes
|
25
|
+
files.map(&:extract_cassettes)
|
26
|
+
end
|
27
|
+
|
28
|
+
def master
|
29
|
+
@master ||= Master.new(@comparator, configuration)
|
30
|
+
end
|
31
|
+
|
32
|
+
# determines what file representation to return for writing to
|
33
|
+
#
|
34
|
+
# 1. master when deltas are disabled
|
35
|
+
#
|
36
|
+
# 2. master when master does not exist. This handles two scenarios:
|
37
|
+
# - master does not exist (if this happens, we should always regenerate master)
|
38
|
+
# - we want to regenerate master (this assumes some other mechanism is responsible
|
39
|
+
# for deleting master to make this work)
|
40
|
+
#
|
41
|
+
# 3. current_delta
|
42
|
+
def primary
|
43
|
+
return master if configuration.deltas_disabled?
|
44
|
+
|
45
|
+
return master unless File.exist?(master.file_path)
|
46
|
+
|
47
|
+
current_delta
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module Blockbuster
|
2
|
+
# Manages cassette packaging and unpackaging
|
3
|
+
class Manager
|
4
|
+
include Blockbuster::OutputHelpers
|
5
|
+
|
6
|
+
attr_accessor :comparator
|
7
|
+
|
8
|
+
def initialize(instance_configuration = Blockbuster::Configuration.new)
|
9
|
+
yield configuration if block_given?
|
10
|
+
|
11
|
+
@configuration ||= instance_configuration
|
12
|
+
|
13
|
+
@comparator = Comparator.new(@configuration)
|
14
|
+
@extraction_list = ExtractionList.new(@comparator, @configuration)
|
15
|
+
end
|
16
|
+
|
17
|
+
def configuration
|
18
|
+
@configuration ||= Blockbuster::Configuration.new
|
19
|
+
end
|
20
|
+
|
21
|
+
# extracts cassettes from a tar.gz file
|
22
|
+
#
|
23
|
+
# tracks a md5 hash of each file in the tarball
|
24
|
+
def rent
|
25
|
+
master_file_path = @extraction_list.master.file_path
|
26
|
+
|
27
|
+
unless File.exist?(master_file_path)
|
28
|
+
silent_puts "File does not exist: #{master_file_path}."
|
29
|
+
return false
|
30
|
+
end
|
31
|
+
|
32
|
+
remove_existing_cassette_directory if configuration.wipe_cassette_dir
|
33
|
+
|
34
|
+
silent_puts "Extracting VCR cassettes to #{configuration.cassette_dir}"
|
35
|
+
|
36
|
+
@extraction_list.extract_cassettes
|
37
|
+
|
38
|
+
@comparator.store_current_delta_files if configuration.deltas_enabled?
|
39
|
+
end
|
40
|
+
|
41
|
+
# repackages cassettes into a compressed tarball
|
42
|
+
def drop_off(force: false)
|
43
|
+
if comparator.rewind?(configuration.cassette_files) || force
|
44
|
+
silent_puts "Recreating cassette file #{@extraction_list.primary.target_path}"
|
45
|
+
@extraction_list.primary.create_cassette_file
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
alias setup rent
|
50
|
+
alias teardown drop_off
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def remove_existing_cassette_directory
|
55
|
+
return if configuration.local_mode
|
56
|
+
|
57
|
+
dir = configuration.cassette_dir
|
58
|
+
|
59
|
+
silent_puts "Wiping cassettes directory: #{dir}"
|
60
|
+
FileUtils.rm_r(dir) if Dir.exist?(dir)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Blockbuster
|
2
|
+
# Master file object
|
3
|
+
class Master
|
4
|
+
include Blockbuster::Extractor
|
5
|
+
include Blockbuster::Packager
|
6
|
+
|
7
|
+
attr_reader :file_name, :configuration
|
8
|
+
|
9
|
+
def initialize(comparator, configuration)
|
10
|
+
@configuration = configuration
|
11
|
+
@comparator = comparator
|
12
|
+
@file_name = configuration.master_tar_file
|
13
|
+
end
|
14
|
+
|
15
|
+
# read path
|
16
|
+
def file_path
|
17
|
+
configuration.master_tar_file_path
|
18
|
+
end
|
19
|
+
|
20
|
+
# write path (master will always write to the same file name)
|
21
|
+
def target_path
|
22
|
+
file_path
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
metadata
ADDED
@@ -0,0 +1,171 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: blockbuster
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.4.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Lukas Eklund
|
8
|
+
- Alexander Bergman
|
9
|
+
- Hassan Shahid
|
10
|
+
autorequire:
|
11
|
+
bindir: exe
|
12
|
+
cert_chain: []
|
13
|
+
date: 2016-08-18 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: bundler
|
17
|
+
requirement: !ruby/object:Gem::Requirement
|
18
|
+
requirements:
|
19
|
+
- - "~>"
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '1.10'
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
requirements:
|
26
|
+
- - "~>"
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
version: '1.10'
|
29
|
+
- !ruby/object:Gem::Dependency
|
30
|
+
name: bundler-audit
|
31
|
+
requirement: !ruby/object:Gem::Requirement
|
32
|
+
requirements:
|
33
|
+
- - ">="
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: '0'
|
36
|
+
type: :development
|
37
|
+
prerelease: false
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '0'
|
43
|
+
- !ruby/object:Gem::Dependency
|
44
|
+
name: minitest
|
45
|
+
requirement: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '0'
|
50
|
+
type: :development
|
51
|
+
prerelease: false
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
- !ruby/object:Gem::Dependency
|
58
|
+
name: mocha
|
59
|
+
requirement: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - ">="
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '0'
|
64
|
+
type: :development
|
65
|
+
prerelease: false
|
66
|
+
version_requirements: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: '0'
|
71
|
+
- !ruby/object:Gem::Dependency
|
72
|
+
name: pry
|
73
|
+
requirement: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - ">="
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
type: :development
|
79
|
+
prerelease: false
|
80
|
+
version_requirements: !ruby/object:Gem::Requirement
|
81
|
+
requirements:
|
82
|
+
- - ">="
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: '0'
|
85
|
+
- !ruby/object:Gem::Dependency
|
86
|
+
name: rake
|
87
|
+
requirement: !ruby/object:Gem::Requirement
|
88
|
+
requirements:
|
89
|
+
- - "~>"
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: '10.0'
|
92
|
+
type: :development
|
93
|
+
prerelease: false
|
94
|
+
version_requirements: !ruby/object:Gem::Requirement
|
95
|
+
requirements:
|
96
|
+
- - "~>"
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '10.0'
|
99
|
+
- !ruby/object:Gem::Dependency
|
100
|
+
name: rubocop
|
101
|
+
requirement: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - "~>"
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: '0.37'
|
106
|
+
type: :development
|
107
|
+
prerelease: false
|
108
|
+
version_requirements: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - "~>"
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: '0.37'
|
113
|
+
description:
|
114
|
+
email:
|
115
|
+
- leklund@fastly.com
|
116
|
+
- alexander@fastly.com
|
117
|
+
- hassan@fastly.com
|
118
|
+
executables: []
|
119
|
+
extensions: []
|
120
|
+
extra_rdoc_files: []
|
121
|
+
files:
|
122
|
+
- ".gitignore"
|
123
|
+
- ".rubocop.yml"
|
124
|
+
- ".ruby-version"
|
125
|
+
- ".travis.yml"
|
126
|
+
- CHANGELOG.md
|
127
|
+
- CODE_OF_CONDUCT.md
|
128
|
+
- Gemfile
|
129
|
+
- LICENSE.txt
|
130
|
+
- README.md
|
131
|
+
- Rakefile
|
132
|
+
- bin/console
|
133
|
+
- bin/setup
|
134
|
+
- blockbuster.gemspec
|
135
|
+
- lib/blockbuster.rb
|
136
|
+
- lib/blockbuster/comparator.rb
|
137
|
+
- lib/blockbuster/concerns/extractor.rb
|
138
|
+
- lib/blockbuster/concerns/file_helpers.rb
|
139
|
+
- lib/blockbuster/concerns/output_helpers.rb
|
140
|
+
- lib/blockbuster/concerns/packager.rb
|
141
|
+
- lib/blockbuster/configuration.rb
|
142
|
+
- lib/blockbuster/delta.rb
|
143
|
+
- lib/blockbuster/extraction_list.rb
|
144
|
+
- lib/blockbuster/manager.rb
|
145
|
+
- lib/blockbuster/master.rb
|
146
|
+
- lib/blockbuster/version.rb
|
147
|
+
homepage: https://github.com/fastly/blockbuster
|
148
|
+
licenses:
|
149
|
+
- MIT
|
150
|
+
metadata: {}
|
151
|
+
post_install_message:
|
152
|
+
rdoc_options: []
|
153
|
+
require_paths:
|
154
|
+
- lib
|
155
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - ">="
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '0'
|
160
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
161
|
+
requirements:
|
162
|
+
- - ">="
|
163
|
+
- !ruby/object:Gem::Version
|
164
|
+
version: '0'
|
165
|
+
requirements: []
|
166
|
+
rubyforge_project:
|
167
|
+
rubygems_version: 2.5.1
|
168
|
+
signing_key:
|
169
|
+
specification_version: 4
|
170
|
+
summary: Packaging VCR cassettes for git since 2016
|
171
|
+
test_files: []
|