gemvault 0.1.4 → 0.2.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.containerignore +10 -0
  3. data/.rubocop.yml +8 -1
  4. data/CHANGELOG.md +56 -0
  5. data/CLAUDE.md +6 -5
  6. data/Dockerfile.test +14 -0
  7. data/README.md +18 -6
  8. data/Rakefile +19 -0
  9. data/docs/tarvault-findings.md +186 -0
  10. data/docs/tarvault.md +42 -0
  11. data/issues.rec +540 -17
  12. data/lib/bundler/plugin/vault_source.rb +61 -73
  13. data/lib/bundler/plugin/vaulted_gem.rb +66 -0
  14. data/lib/gemvault/archive_entry.rb +6 -0
  15. data/lib/gemvault/cli/command.rb +9 -5
  16. data/lib/gemvault/cli/commands/extract.rb +11 -11
  17. data/lib/gemvault/cli/commands/new.rb +3 -2
  18. data/lib/gemvault/cli/commands/remove.rb +15 -5
  19. data/lib/gemvault/cli/commands/upgrade.rb +72 -0
  20. data/lib/gemvault/dbvault.rb +101 -0
  21. data/lib/gemvault/deprecation.rb +48 -0
  22. data/lib/gemvault/gem_entry.rb +20 -24
  23. data/lib/gemvault/gem_extraction.rb +41 -0
  24. data/lib/gemvault/gem_reference/any_version.rb +6 -2
  25. data/lib/gemvault/gem_reference/parser.rb +44 -0
  26. data/lib/gemvault/gem_reference/specific_version.rb +9 -25
  27. data/lib/gemvault/gem_reference.rb +13 -45
  28. data/lib/gemvault/manifest.rb +78 -0
  29. data/lib/gemvault/tarball.rb +53 -0
  30. data/lib/gemvault/tarvault.rb +139 -0
  31. data/lib/gemvault/vault.rb +55 -157
  32. data/lib/gemvault/vault_path.rb +33 -0
  33. data/lib/gemvault/vault_session.rb +16 -0
  34. data/lib/gemvault/vault_upgrade.rb +94 -0
  35. data/lib/gemvault/version.rb +1 -1
  36. data/lib/gemvault.rb +2 -0
  37. data/lib/rubygems/resolver/vault_set.rb +5 -7
  38. data/lib/rubygems/source/vault.rb +28 -32
  39. data/lib/rubygems_plugin.rb +17 -6
  40. metadata +21 -33
@@ -1,39 +1,71 @@
1
- require "fileutils"
1
+ require "pathname"
2
+ require_relative "vaulted_gem"
2
3
  require_relative "../../gemvault/vault"
4
+ require_relative "../../gemvault/gem_entry"
3
5
 
4
6
  module Bundler
5
7
  module Plugin
6
- # Bundler plugin source that installs gems from a .gemv vault file.
8
+ # Bundler plugin source that installs gems from a .gemv vault file. A thin
9
+ # doorway over the Bundler Source API: it resolves the vault path, hands
10
+ # Bundler the gemspecs it can install, and delegates each gem's placement in
11
+ # the bundle to a VaultedGem.
7
12
  class VaultSource
8
13
  def initialize(opts)
9
14
  super
10
- @vault_path = resolve_vault_path(@uri)
15
+ @vault_path = Pathname(@uri).expand_path(Bundler.root)
16
+ @allow_remote = false
11
17
  end
12
18
 
13
19
  def fetch_gemspec_files
14
20
  validate_vault_exists!
15
21
 
16
22
  Gemvault::Vault.open(@vault_path) do |vault|
17
- vault.gem_entries.map { |entry| gemspec_file_for(vault, entry) }
23
+ vault.gem_entries
24
+ .map { |entry| vault.spec_from_blob(entry) }
25
+ .filter_map { |spec| gemspec_file_for(spec) }
18
26
  end
19
27
  end
20
28
 
29
+ # Bundler first asks a source, in local mode, which gems are already
30
+ # unpacked so it can tell what still needs installing, then switches to
31
+ # remote mode to resolve and install the rest. A vault gem is not on the
32
+ # load path until it is unpacked, so advertising one that still lives only
33
+ # inside the archive makes Bundler believe it is installed: it skips the
34
+ # install and the later require fails. Advertise not-yet-unpacked gems
35
+ # only once Bundler has asked for remote specs.
36
+ def remote!
37
+ @allow_remote = true
38
+ end
39
+
40
+ # The inverse of remote!. Bundler's local-only resolve (Definition#check!,
41
+ # reached by `bundle check`) calls local_only! on every source. The base
42
+ # Bundler::Plugin::API::Source defines no such hook, so without this the
43
+ # command dies with NoMethodError; here it simply stops advertising gems
44
+ # that live only inside the archive.
45
+ def local_only!
46
+ @allow_remote = false
47
+ end
48
+
49
+ # Also invoked on every source during resolution (Definition#prefer_local!,
50
+ # for `--prefer-local`). The vault reads from one local file already, so
51
+ # there is no remote to deprioritize.
52
+ def prefer_local!; end
53
+
21
54
  def install(spec, opts = {})
22
- gem_dir = gem_dir_for(spec.full_name)
23
- return use_installed_gem(spec, gem_dir) if File.directory?(gem_dir) && !opts[:force]
55
+ gem = VaultedGem.new(spec)
56
+ return gem.adopt if gem.installed? && !opts[:force]
24
57
 
25
- Bundler.ui.confirm "Installing #{version_message(spec)} from vault #{@uri}"
26
- install_into_bundle(spec, opts)
27
- spec.post_install_message
58
+ Bundler.ui.confirm "Installing #{gem.version_message} from vault #{@uri}"
59
+ extract(spec) { |gem_path| gem.install(gem_path:, build_args: opts[:build_args] || []) }
28
60
  end
29
61
 
30
62
  def options_to_lock
31
63
  {}
32
64
  end
33
65
 
34
- # No source-level install_path to copy: VaultSource#install installs
35
- # each gem into Bundler.bundle_path via RubyGemsGemInstaller, so the
36
- # default Source#cache would dereference a non-existent directory.
66
+ # No source-level install_path to copy: VaultSource#install installs each
67
+ # gem into Bundler.bundle_path via RubyGemsGemInstaller, so the default
68
+ # Source#cache would dereference a non-existent directory.
37
69
  def cache(spec, custom_path = nil); end
38
70
 
39
71
  def to_s
@@ -42,79 +74,35 @@ module Bundler
42
74
 
43
75
  private
44
76
 
45
- def gemspec_file_for(vault, entry)
46
- spec = vault.spec_from_blob(entry.name, entry.version, entry.platform)
47
- gem_dir = gem_dir_for(spec.full_name)
48
- return anchor_gemspec(gem_dir, spec.full_name, spec.to_ruby) if File.directory?(gem_dir)
49
-
50
- write_tmp_gemspec(spec.full_name, spec.to_ruby)
51
- end
77
+ def gemspec_file_for(spec)
78
+ gem = VaultedGem.new(spec)
79
+ return gem.anchor(spec.to_ruby) if gem.installed?
80
+ return nil unless @allow_remote
52
81
 
53
- def write_tmp_gemspec(full_name, spec_ruby)
54
- gemspec_dir = File.join(Bundler.tmp("vault_source"), "specifications")
55
- FileUtils.mkdir_p(gemspec_dir)
56
- gemspec_path = File.join(gemspec_dir, "#{full_name}.gemspec")
57
- File.write(gemspec_path, spec_ruby)
58
- gemspec_path
82
+ write_tmp_gemspec(spec)
59
83
  end
60
84
 
61
- def use_installed_gem(spec, gem_dir)
62
- Bundler.ui.debug "Using #{version_message(spec)} from vault #{@uri}"
63
- spec.full_gem_path = gem_dir
64
- spec.loaded_from = File.join(gem_dir, "#{spec.full_name}.gemspec")
65
- nil
85
+ def write_tmp_gemspec(spec)
86
+ tmp_dir = Pathname(Bundler.tmp("vault_source"))
87
+ gemspec_dir = tmp_dir / "specifications"
88
+ gemspec_dir.mkpath
89
+ gemspec = gemspec_dir / "#{spec.full_name}.gemspec"
90
+ gemspec.write(spec.to_ruby)
91
+ gemspec.to_s
66
92
  end
67
93
 
68
- def install_into_bundle(spec, opts)
94
+ def extract(spec, &)
69
95
  Gemvault::Vault.open(@vault_path) do |vault|
70
- vault.with_gem_file(spec.name, spec.version.to_s, platform: spec.platform.to_s) do |gem_path|
71
- installed_spec = build_installer(gem_path, opts).install
72
- gem_dir = installed_spec.full_gem_path
73
- spec.full_gem_path = gem_dir
74
- spec.loaded_from = anchor_gemspec(gem_dir, spec.full_name, installed_spec.to_ruby)
75
- end
96
+ vault.with_gem_file(entry_for(spec), &)
76
97
  end
77
98
  end
78
99
 
79
- def build_installer(gem_path, opts)
80
- require "bundler/rubygems_gem_installer"
81
-
82
- Bundler::RubyGemsGemInstaller.at(
83
- gem_path,
84
- install_dir: Bundler.bundle_path.to_s,
85
- bin_dir: Bundler.system_bindir.to_s,
86
- ignore_dependencies: true,
87
- wrappers: true,
88
- env_shebang: true,
89
- build_args: opts[:build_args] || [],
90
- )
91
- end
92
-
93
- def version_message(spec)
94
- message = "#{spec.name} #{spec.version}"
95
- message += " (#{spec.platform})" if spec.platform != Gem::Platform::RUBY && !spec.platform.nil?
96
- message
97
- end
98
-
99
- def resolve_vault_path(uri)
100
- File.expand_path(uri, Bundler.root.to_s)
101
- end
102
-
103
- # Bundler computes full_gem_path as dirname(loaded_from) for plugin
104
- # sources, so the gemspec must live inside the gem directory -- not
105
- # in specifications/ -- for load paths to resolve correctly.
106
- def anchor_gemspec(gem_dir, full_name, spec_ruby)
107
- gemspec_path = File.join(gem_dir, "#{full_name}.gemspec")
108
- File.write(gemspec_path, spec_ruby) unless File.exist?(gemspec_path)
109
- gemspec_path
110
- end
111
-
112
- def gem_dir_for(full_name)
113
- File.join(Bundler.bundle_path, "gems", full_name)
100
+ def entry_for(spec)
101
+ Gemvault::GemEntry.from_spec(spec)
114
102
  end
115
103
 
116
104
  def validate_vault_exists!
117
- return if File.file?(@vault_path)
105
+ return if @vault_path.file?
118
106
 
119
107
  raise Bundler::PathError,
120
108
  "Could not find vault '#{@uri}' referenced in Gemfile " \
@@ -0,0 +1,66 @@
1
+ require "pathname"
2
+
3
+ module Bundler
4
+ module Plugin
5
+ # A gem sourced from a vault as it lives (or will live) inside the bundle.
6
+ # Owns where the gem unpacks, whether it is already installed, installing an
7
+ # extracted .gem file, and anchoring its gemspec inside the gem directory --
8
+ # Bundler derives full_gem_path from dirname(loaded_from), so the gemspec
9
+ # must sit beside the gem, not in specifications/.
10
+ class VaultedGem
11
+ def initialize(spec)
12
+ @spec = spec
13
+ @gem_dir = Bundler.bundle_path.join("gems", spec.full_name)
14
+ end
15
+
16
+ def installed?
17
+ @gem_dir.directory?
18
+ end
19
+
20
+ def version_message
21
+ message = "#{@spec.name} #{@spec.version}"
22
+ message += " (#{@spec.platform})" if @spec.platform != Gem::Platform::RUBY && !@spec.platform.nil?
23
+ message
24
+ end
25
+
26
+ # Point the Bundler spec at the already-unpacked gem.
27
+ def adopt
28
+ Bundler.ui.debug "Using #{version_message} from vault"
29
+ @spec.full_gem_path = @gem_dir.to_s
30
+ @spec.loaded_from = anchor(@spec.to_ruby)
31
+ nil
32
+ end
33
+
34
+ # Install the extracted .gem file into the bundle, point the spec at it,
35
+ # and return its post-install message.
36
+ def install(gem_path:, build_args:)
37
+ installed = installer(gem_path:, build_args:).install
38
+ @spec.full_gem_path = installed.full_gem_path
39
+ @spec.loaded_from = anchor(installed.to_ruby, dir: installed.full_gem_path)
40
+ @spec.post_install_message
41
+ end
42
+
43
+ def anchor(spec_ruby, dir: @gem_dir)
44
+ gemspec = Pathname(dir).join("#{@spec.full_name}.gemspec")
45
+ gemspec.write(spec_ruby) unless gemspec.exist?
46
+ gemspec.to_s
47
+ end
48
+
49
+ private
50
+
51
+ def installer(gem_path:, build_args:)
52
+ require "bundler/rubygems_gem_installer"
53
+
54
+ Bundler::RubyGemsGemInstaller.at(
55
+ gem_path,
56
+ install_dir: Bundler.bundle_path.to_s,
57
+ bin_dir: Bundler.system_bindir.to_s,
58
+ ignore_dependencies: true,
59
+ wrappers: true,
60
+ env_shebang: true,
61
+ build_args:,
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,6 @@
1
+ module Gemvault
2
+ # One member of a vault's tar archive: a named blob of bytes. A value object
3
+ # so archive code names what it moves around instead of juggling raw arrays.
4
+ class ArchiveEntry < Data.define(:name, :bytes)
5
+ end
6
+ end
@@ -1,5 +1,6 @@
1
1
  require "command_kit/command"
2
2
  require_relative "../vault"
3
+ require_relative "../vault_path"
3
4
 
4
5
  module Gemvault
5
6
  class CLI
@@ -7,11 +8,14 @@ module Gemvault
7
8
  class Command < CommandKit::Command
8
9
  private
9
10
 
10
- def with_vault(path, create: false, &block)
11
- Gemvault::Vault.open(path, create: create, &block)
12
- rescue Gemvault::Vault::Error => e
13
- print_error(e.message)
14
- exit(1)
11
+ def with_vault(locator, create: false, &block)
12
+ begin
13
+ path = VaultPath.resolve(locator)
14
+ Gemvault::Vault.open(path, create:, &block)
15
+ rescue Gemvault::Vault::Error => e
16
+ print_error(e.message)
17
+ exit(1)
18
+ end
15
19
  end
16
20
  end
17
21
  end
@@ -1,4 +1,4 @@
1
- require "fileutils"
1
+ require "pathname"
2
2
  require_relative "../command"
3
3
 
4
4
  module Gemvault
@@ -25,21 +25,21 @@ module Gemvault
25
25
  desc: "Output directory"
26
26
 
27
27
  def run(vault, name, version = nil)
28
- output_dir = options[:output]
28
+ output_dir = Pathname(options[:output])
29
29
 
30
30
  with_vault(vault) do |v|
31
- ::FileUtils.mkdir_p(output_dir)
32
- entries = matching_entries(v, name, version)
31
+ output_dir.mkpath
32
+ entries = matching_entries(vault: v, name:, version:)
33
33
  abort_missing_gem(name) if entries.empty?
34
- extract_entries(v, entries, output_dir)
34
+ extract_entries(vault: v, entries:, output_dir:)
35
35
  end
36
36
  end
37
37
 
38
38
  private
39
39
 
40
- def matching_entries(vault, name, version)
41
- entries = vault.gem_entries.select { |e| e.name == name }
42
- version ? entries.select { |e| e.version == version } : entries
40
+ def matching_entries(vault:, name:, version:)
41
+ entries = vault.gem_entries.select { |entry| entry.name == name }
42
+ version ? entries.select { |entry| entry.version == version } : entries
43
43
  end
44
44
 
45
45
  def abort_missing_gem(name)
@@ -47,10 +47,10 @@ module Gemvault
47
47
  exit(1)
48
48
  end
49
49
 
50
- def extract_entries(vault, entries, output_dir)
50
+ def extract_entries(vault:, entries:, output_dir:)
51
51
  entries.each do |entry|
52
- data = vault.gem_data(entry.name, entry.version, platform: entry.platform)
53
- File.binwrite(File.join(output_dir, entry.filename), data)
52
+ data = vault.gem_data(entry)
53
+ (output_dir / entry.filename).binwrite(data)
54
54
  puts "Extracted #{entry.filename}"
55
55
  end
56
56
  end
@@ -1,3 +1,4 @@
1
+ require "pathname"
1
2
  require_relative "../command"
2
3
 
3
4
  module Gemvault
@@ -12,9 +13,9 @@ module Gemvault
12
13
  desc: "Vault name (auto-appends .gemv)"
13
14
 
14
15
  def run(name)
15
- path = name.end_with?(".gemv") ? name : "#{name}.gemv"
16
+ path = Pathname(name.end_with?(".gemv") ? name : "#{name}.gemv")
16
17
 
17
- if File.exist?(path)
18
+ if path.exist?
18
19
  print_error("#{path} already exists")
19
20
  exit(1)
20
21
  end
@@ -26,15 +26,25 @@ module Gemvault
26
26
  desc: "Gem version (overrides positional and NAME-VERSION forms)"
27
27
 
28
28
  def run(vault, name, positional_version = nil)
29
- ref = Gemvault::GemReference.parse(name, version: options[:version] || positional_version)
30
- with_vault(vault) { |v| report_removal(v.remove(ref)) }
31
- rescue Gemvault::GemReference::NonExactVersionError => e
32
- print_error(e.message)
33
- exit(1)
29
+ begin
30
+ version = options[:version] || positional_version
31
+ reference = Gemvault::GemReference.parse(name, version:)
32
+ remove_matching(vault:, reference:)
33
+ rescue Gemvault::GemReference::NonExactVersionError => e
34
+ print_error(e.message)
35
+ exit(1)
36
+ end
34
37
  end
35
38
 
36
39
  private
37
40
 
41
+ def remove_matching(vault:, reference:)
42
+ with_vault(vault) do |v|
43
+ removed = v.remove(reference)
44
+ report_removal(removed)
45
+ end
46
+ end
47
+
38
48
  def report_removal(count)
39
49
  if count.zero?
40
50
  print_error("No matching gem found")
@@ -0,0 +1,72 @@
1
+ require_relative "../command"
2
+ require_relative "../../vault_path"
3
+ require_relative "../../vault_upgrade"
4
+
5
+ module Gemvault
6
+ class CLI
7
+ module Commands
8
+ # Upgrades a vault to the current storage format (e.g. a SQLite Dbvault
9
+ # to a Tarvault), preserving every gem and its timestamp.
10
+ class Upgrade < Command
11
+ description "Upgrade a vault to the current storage format"
12
+
13
+ argument :vault, required: true,
14
+ usage: "VAULT",
15
+ desc: "Vault file"
16
+
17
+ option :dry_run, desc: "Show what would change without modifying the vault"
18
+ option :no_backup, desc: "Do not write a .bak copy before upgrading"
19
+
20
+ def run(vault)
21
+ begin
22
+ upgrade = build_upgrade(vault)
23
+ dispatch(vault:, upgrade:)
24
+ rescue Gemvault::Vault::Error => e
25
+ print_error(e.message)
26
+ exit(1)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def build_upgrade(vault)
33
+ path = VaultPath.resolve(vault)
34
+ Gemvault::VaultUpgrade.new(path, backup: backup?)
35
+ end
36
+
37
+ def backup?
38
+ !options[:no_backup]
39
+ end
40
+
41
+ def dispatch(vault:, upgrade:)
42
+ summary = upgrade.plan
43
+ case decision(summary)
44
+ in { no_op: true }
45
+ report_no_op(vault:, summary:)
46
+ in { dry_run: true }
47
+ report_dry_run(vault:, summary:)
48
+ else
49
+ upgrade.call
50
+ report_upgraded(vault:, summary:)
51
+ end
52
+ end
53
+
54
+ def decision(summary)
55
+ { no_op: summary.no_op?, dry_run: options[:dry_run] }
56
+ end
57
+
58
+ def report_no_op(vault:, summary:)
59
+ puts "#{vault} is already current (format #{summary.to_version})"
60
+ end
61
+
62
+ def report_dry_run(vault:, summary:)
63
+ puts "Would upgrade #{vault}: #{summary} (#{summary.gem_count} gems)"
64
+ end
65
+
66
+ def report_upgraded(vault:, summary:)
67
+ puts "Upgraded #{vault}: #{summary} (#{summary.gem_count} gems)"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,101 @@
1
+ require "sqlite3"
2
+ require "pathname"
3
+ require_relative "vault"
4
+ require_relative "vault_session"
5
+ require_relative "gem_extraction"
6
+ require_relative "gem_entry"
7
+ require_relative "deprecation"
8
+
9
+ module Gemvault
10
+ # Read-only reader for a SQLite-backed vault (a "Dbvault"). Attempts to write
11
+ # are refused with a pointer to run gemvault upgrade, and opening a vault
12
+ # emits a one-time notice.
13
+ class Dbvault
14
+ extend VaultSession
15
+ include GemExtraction
16
+
17
+ FORMAT_VERSION = 1
18
+
19
+ attr_reader :path
20
+
21
+ def initialize(path)
22
+ @path = Pathname(path).expand_path
23
+ open_vault!
24
+ end
25
+
26
+ def add(*)
27
+ raise Vault::ReadOnlyError, read_only_message
28
+ end
29
+
30
+ def remove(*)
31
+ raise Vault::ReadOnlyError, read_only_message
32
+ end
33
+
34
+ def gem_data(entry)
35
+ row = @db.execute(
36
+ "SELECT data FROM gems WHERE name = ? AND version = ? AND platform = ?",
37
+ [entry.name, entry.version, entry.platform],
38
+ ).first
39
+ raise Vault::NotFoundError, "Gem not found: #{entry}" unless row
40
+
41
+ row["data"]
42
+ end
43
+
44
+ def gem_entries
45
+ @db.execute(
46
+ "SELECT name, version, platform, created_at FROM gems ORDER BY name, version",
47
+ ).map { |row| GemEntry.new(**row.transform_keys(&:to_sym)) }
48
+ end
49
+
50
+ def size
51
+ @db.execute("SELECT COUNT(*) AS count FROM gems").first["count"]
52
+ end
53
+
54
+ def close
55
+ @db.close if @db && !@db.closed?
56
+ end
57
+
58
+ def closed?
59
+ @db.nil? || @db.closed?
60
+ end
61
+
62
+ def format_version
63
+ row = @db.execute("SELECT value FROM metadata WHERE key = 'vault_version'").first
64
+ return FORMAT_VERSION unless row
65
+
66
+ row["value"].to_i
67
+ end
68
+
69
+ private
70
+
71
+ def open_vault!
72
+ raise Vault::NotFoundError, "Vault not found: #{@path}" unless @path.exist?
73
+
74
+ validate_sqlite!
75
+ @db = new_database
76
+ Vault.assert_readable!(version: format_version, path: @path)
77
+ Deprecation.warn_once(deprecation_message)
78
+ end
79
+
80
+ def new_database
81
+ db = SQLite3::Database.new(@path.to_s)
82
+ db.results_as_hash = true
83
+ db
84
+ end
85
+
86
+ def validate_sqlite!
87
+ return if @path.binread(Vault::SQLITE_MAGIC.bytesize) == Vault::SQLITE_MAGIC
88
+
89
+ raise Vault::Error, "Not a valid vault file (not SQLite): #{@path}"
90
+ end
91
+
92
+ def read_only_message
93
+ "#{@path} uses the deprecated read-only SQLite format. Migrate it first: gemvault upgrade #{@path}"
94
+ end
95
+
96
+ def deprecation_message
97
+ "SQLite vaults are deprecated and read-only; support will be removed in a future release (0.3-0.5). " \
98
+ "Migrate #{@path} with: gemvault upgrade #{@path}"
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,48 @@
1
+ module Gemvault
2
+ # Emits deprecation notices once per unique message, to a configurable IO
3
+ # (stderr in production). Silenceable per-block or via an environment opt-out
4
+ # so automated pipelines that consume a legacy vault are not spammed.
5
+ module Deprecation
6
+ extend self
7
+
8
+ ENV_KEY = "GEMVAULT_SILENCE_DEPRECATIONS".freeze
9
+
10
+ attr_writer :output
11
+
12
+ def output
13
+ @output ||= $stderr
14
+ end
15
+
16
+ def warn_once(message)
17
+ return if silenced?
18
+ return unless seen.add?(message)
19
+
20
+ output.puts("gemvault: #{message}")
21
+ end
22
+
23
+ def silence
24
+ previous = @silenced
25
+ @silenced = true
26
+ begin
27
+ yield
28
+ ensure
29
+ @silenced = previous
30
+ end
31
+ end
32
+
33
+ def silenced?
34
+ @silenced || ENV.key?(ENV_KEY)
35
+ end
36
+
37
+ def reset!
38
+ @seen = Set.new
39
+ @silenced = false
40
+ end
41
+
42
+ private
43
+
44
+ def seen
45
+ @seen ||= Set.new
46
+ end
47
+ end
48
+ end
@@ -1,38 +1,34 @@
1
1
  module Gemvault
2
- # Value object for one gem row (name, version, platform, created_at) in a vault.
3
- class GemEntry
4
- attr_reader :name, :version, :platform, :created_at
2
+ # Value object for one gem's identity (name, version, platform) and the time
3
+ # it was stored in a vault. Equality, hashing, and to_h come from Data.
4
+ class GemEntry < Data.define(:name, :version, :platform, :created_at)
5
+ RUBY_PLATFORM_NAME = "ruby".freeze
5
6
 
6
- def initialize(name:, version:, platform: "ruby", created_at: nil)
7
- @name = name
8
- @version = version
9
- @platform = platform
10
- @created_at = created_at
7
+ ##
8
+ # Builds an entry from a <tt>Gem::Specification</tt> (or any object with
9
+ # +name+, +version+, and +platform+), stringifying the version and platform.
10
+ def self.from_spec(spec, created_at: nil)
11
+ new(name: spec.name, version: spec.version.to_s, platform: spec.platform.to_s, created_at:)
11
12
  end
12
13
 
13
- def full_name
14
- platform == "ruby" ? "#{name}-#{version}" : "#{name}-#{version}-#{platform}"
14
+ def initialize(name:, version:, platform: RUBY_PLATFORM_NAME, created_at: nil)
15
+ super
15
16
  end
16
17
 
17
- def filename
18
- "#{full_name}.gem"
19
- end
18
+ def ruby_platform? = platform == RUBY_PLATFORM_NAME
20
19
 
21
- def to_s
22
- platform == "ruby" ? "#{name}-#{version}" : "#{name}-#{version} (#{platform})"
20
+ def full_name
21
+ ruby_platform? ? "#{name}-#{version}" : "#{name}-#{version}-#{platform}"
23
22
  end
24
23
 
25
- def ==(other)
26
- other.is_a?(self.class) &&
27
- name == other.name &&
28
- version == other.version &&
29
- platform == other.platform
30
- end
24
+ def filename = "#{full_name}.gem"
31
25
 
32
- alias eql? ==
26
+ def same_identity_as?(other)
27
+ name == other.name && version == other.version && platform == other.platform
28
+ end
33
29
 
34
- def hash
35
- [name, version, platform].hash
30
+ def to_s
31
+ ruby_platform? ? "#{name}-#{version}" : "#{name}-#{version} (#{platform})"
36
32
  end
37
33
  end
38
34
  end