summaryrefslogtreecommitdiff
path: root/tasks/gemfile_util.rb
blob: 03a729148a5dc2a099bbf7d68dad8aa576489dc1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
require "rubygems"
require "bundler"
require "shellwords"
require "set"

module GemfileUtil
  #
  # Adds `override: true`, which allows your statement to override any other
  # gem statement about the same gem in the Gemfile.
  #
  def gem(name, *args)
    options = args[-1].is_a?(Hash) ? args[-1] : {}

    # Unless we're finished with everything, ignore gems that are being overridden
    unless overridden_gems == :finished
      # If it's a path or override gem, it overrides whatever else is there.
      if options[:path] || options[:override]
        options.delete(:override)
        warn_if_replacing(name, overridden_gems[name], args)
        overridden_gems[name] = args
        return

      # If there's an override gem, and we're *not* an override gem, don't do anything
      elsif overridden_gems[name]
        warn_if_replacing(name, args, overridden_gems[name])
        return
      end
    end

    # Otherwise, add the gem normally
    super
  rescue
    puts $!.backtrace
    raise
  end

  def overridden_gems
    @overridden_gems ||= {}
  end

  #
  # Just before we finish the Gemfile, finish up the override gems
  #
  def to_definition(*args)
    complete_overrides
    super
  end

  def complete_overrides
    to_override = overridden_gems
    unless to_override == :finished
      @overridden_gems = :finished
      to_override.each do |name, args|
        gem name, *args
      end
    end
  end

  #
  # Include all gems in the locked gemfile.
  #
  # @param gemfile_path Path to the Gemfile to load (relative to your Gemfile)
  # @param lockfile_path Path to the Gemfile to load (relative to your Gemfile).
  #          Defaults to <gemfile_path>.lock.
  # @param groups A list of groups to include (whitelist). If not passed (or set
  #          to nil), all gems will be selected.
  # @param without_groups A list of groups to ignore. Gems will be excluded from
  #          the results if all groups they belong to are ignored. This matches
  #          bundler's `without` behavior.
  # @param gems A list of gems to include above and beyond the given groups.
  #          Gems in this list must be explicitly included in the Gemfile
  #          with a `gem "gem_name", ...` line or they will be silently
  #          ignored.
  # @param copy_groups Whether to copy the groups over from the old lockfile to
  #          the new. Use this when the new lockfile has the same convention for
  #          groups as the old. Defaults to `false`.
  #
  def include_locked_gemfile(gemfile_path, lockfile_path = "#{gemfile_path}.lock", groups: nil, without_groups: nil, gems: [], copy_groups: false)
    # Parse the desired lockfile
    gemfile_path = Pathname.new(gemfile_path).expand_path(Bundler.default_gemfile.dirname).realpath
    lockfile_path = Pathname.new(lockfile_path).expand_path(Bundler.default_gemfile.dirname).realpath

    # Calculate relative_to
    relative_to = Bundler.default_gemfile.dirname.realpath

    # Call out to create-override-gemfile to read the Gemfile+Gemfile.lock (bundler does not work well if you do two things in one process)
    create_override_gemfile_bin = File.expand_path("../bin/create-override-gemfile", __FILE__)
    arguments = [
      "--gemfile", gemfile_path,
      "--lockfile", lockfile_path,
      "--override"
    ]
    arguments += [ "--relative-to", relative_to ] if relative_to != "."
    arguments += Array(groups).flat_map { |group| [ "--group", group ] }
    arguments += Array(without_groups).flat_map { |without| [ "--without", without ] }
    arguments += Array(gems).flat_map { |name| [ "--gem", name ] }
    arguments << "--copy-groups" if copy_groups
    cmd = Shellwords.join([ Gem.ruby, "-S", create_override_gemfile_bin, *arguments ])
    output = nil
    Bundler.ui.info("> #{cmd}")
    Bundler.with_clean_env do
      output = `#{cmd}`
    end
    instance_eval(output, cmd, 1)
  end

  #
  # Include all gems in the locked gemfile.
  #
  # @param current_gemfile The Gemfile you are currently loading (`self`).
  # @param gemfile_path Path to the Gemfile to load (relative to your Gemfile)
  # @param lockfile_path Path to the Gemfile to load (relative to your Gemfile).
  #          Defaults to <gemfile_path>.lock.
  # @param groups A list of groups to include (whitelist). If not passed (or set
  #          to nil), all gems will be selected.
  # @param without_groups A list of groups to ignore. Gems will be excluded from
  #          the results if all groups they belong to are ignored. This matches
  #          bundler's `without` behavior.
  # @param gems A list of gems to include above and beyond the given groups.
  #          Gems in this list must be explicitly included in the Gemfile
  #          with a `gem "gem_name", ...` line or they will be silently
  #          ignored.
  # @param copy_groups Whether to copy the groups over from the old lockfile to
  #          the new. Use this when the new lockfile has the same convention for
  #          groups as the old. Defaults to `false`.
  #
  def self.include_locked_gemfile(current_gemfile, gemfile_path, lockfile_path = "#{gemfile_path}.lock", groups: nil, without_groups: nil, gems: [], copy_groups: false)
    current_gemfile.instance_eval do
      extend GemfileUtil
      include_locked_gemfile(gemfile_path, lockfile_path, groups: groups, without_groups: without_groups, gems: gems, copy_groups: copy_groups)
    end
  end

  def warn_if_replacing(name, old_args, new_args)
    return if !old_args || !new_args
    if args_to_dep(name, *old_args) =~ args_to_dep(name, *new_args)
      Bundler.ui.debug "Replaced Gemfile dependency #{name} (#{old_args}) with (#{new_args})"
    else
      Bundler.ui.warn "Replaced Gemfile dependency #{name} (#{old_args}) with (#{new_args})"
    end
  end

  def args_to_dep(name, *version, **options)
    version = [">= 0"] if version.empty?
    Bundler::Dependency.new(name, version, options)
  end

  #
  # Reads a bundle, including a gemfile and lockfile.
  #
  # Does no validation, does not update the lockfile or its gems in any way.
  #
  class Bundle
    #
    # Parse the given gemfile/lockfile pair.
    #
    # @return [Bundle] The parsed bundle.
    #
    def self.parse(gemfile_path, lockfile_path = "#{gemfile_path}.lock")
      result = new(gemfile_path, lockfile_path)
      result.gems
      result
    end

    #
    # Create a new Bundle to parse the given gemfile/lockfile pair.
    #
    def initialize(gemfile_path, lockfile_path = "#{gemfile_path}.lock")
      @gemfile_path = gemfile_path
      @lockfile_path = lockfile_path
    end

    #
    # The path to the Gemfile
    #
    attr_reader :gemfile_path

    #
    # The path to the Lockfile
    #
    attr_reader :lockfile_path

    #
    # The list of gems.
    #
    # @return [Hash<String, Hash>] The resulting gems, where key = gem_name, and the
    #           hash has:
    #           - version: version of the gem.
    #           - source info (:source/:git/:ref/:path) from the lockfile
    #           - dependencies: A list of gem names this gem has a runtime
    #             dependency on. Dependencies are transitive: if A depends on B,
    #             and B depends on C, then A has C in its :dependencies list.
    #           - development_dependencies: - A list of gem names this gem has a
    #             development dependency on. Dependencies are transitive: if A
    #             depends on B, and B depends on C, then A has C in its
    #             :development_dependencies list. development dependencies *include*
    #             runtime dependencies.
    #           - groups: The list of groups (symbols) this gem is in. Groups
    #             are transitive: if A has a runtime dependency on B, and A is
    #             in group X, then B is also in group X.
    #           - declared_groups: The list of groups (symbols) this gem was
    #             declared in the Gemfile.
    #
    def gems
      @gems ||= begin
        gems = locks.dup
        gems.each do |name, g|
          if gem_declarations.has_key?(name)
            g[:declared_groups] = gem_declarations[name][:groups]
          else
            g[:declared_groups] = []
          end
          g[:groups] = g[:declared_groups].dup
        end
        # Transitivize groups (since dependencies are already transitive, this is easy)
        gems.each do |name, g|
          g[:dependencies].each do |dep|
            gems[dep][:groups] |= gems[name][:declared_groups].dup
          end
        end
        gems
      end
    end

    #
    # Get the gems (and their deps) in the given group.
    #
    # @param groups A list of groups to include (whitelist). If not passed (or set
    #          to nil), all gems will be selected.
    # @param without_groups A list of groups to ignore. Gems will be excluded from
    #          the results if all groups they belong to are ignored.
    #          This matches bundler's `without` behavior.
    # @param gems A list of gems to include regardless of what groups are included.
    #
    # @return Hash[String, Hash] The resulting gems, where key = gem_name, and the
    #           hash has:
    #           - version: version of the gem.
    #           - source info (:source/:git/:ref/:path) from the lockfile
    #           - dependencies: A list of gem names this gem has a runtime
    #             dependency on. Dependencies are transitive: if A depends on B,
    #             and B depends on C, then A has C in its :dependencies list.
    #           - development_dependencies: - A list of gem names this gem has a
    #             development dependency on. Dependencies are transitive: if A
    #             depends on B, and B depends on C, then A has C in its
    #             :development_dependencies list. development dependencies
    #             *include* runtime dependencies.
    #           - groups: The list of groups (symbols) this gem is in. Groups
    #             are transitive: if A has a runtime dependency on B, and A is
    #             in group X, then B is also in group X.
    #           - declared_groups: The list of groups (symbols) this gem was
    #             declared in the Gemfile.
    #
    def select_gems(groups: nil, without_groups: nil)
      # First, select the gems that match
      result = {}
      gems.each do |name, g|
        dep_groups = g[:declared_groups] - [ :only_a_runtime_dependency_of_other_gems ]
        dep_groups &= groups if groups
        dep_groups -= without_groups if without_groups
        if dep_groups.any?
          result[name] ||= g
          g[:dependencies].each do |dep|
            result[dep] ||= gems[dep]
          end
        end
      end
      result
    end

    #
    # Get all locks from the given lockfile.
    #
    # @return Hash[String, Hash] The resulting gems, where key = gem_name, and the
    #           hash has:
    #           - version: version of the gem.
    #           - source info (:source/:git/:ref/:path)
    #           - dependencies: A list of gem names this gem has a runtime
    #             dependency on. Dependencies are transitive: if A depends on B,
    #             and B depends on C, then A has C in its :dependencies list.
    #           - development_dependencies: - A list of gem names this gem has a
    #             development dependency on. Dependencies are transitive: if A
    #             depends on B, and B depends on C, then A has C in its
    #             :development_dependencies list. development dependencies *include*
    #             runtime dependencies.
    #
    def locks
      @locks ||= begin
        # Grab all the specs from the lockfile
        locks = {}
        parsed_lockfile = Bundler::LockfileParser.new(IO.read(lockfile_path))
        parsed_lockfile.specs.each do |spec|
          # Never include bundler, it can't be bundled and doesn't put itself in
          # the lockfile correctly anyway
          next if spec.name == "bundler"
          # Only the platform-specific locks for now (TODO make it possible to emit all locks)
          next if spec.platform && spec.platform != Gem::Platform::RUBY
          lock = lock_source_metadata(spec)
          lock[:version] = spec.version.to_s
          runtime = spec.dependencies.select { |dep| dep.type == :runtime }
          lock[:dependencies] = Set.new(runtime.map { |dep| dep.name })
          lock[:development_dependencies] = Set.new(spec.dependencies.map { |dep| dep.name })
          lock[:dependencies].delete("bundler")
          lock[:development_dependencies].delete("bundler")
          locks[spec.name] = lock
        end

        # Transitivize the deps.
        locks.each do |name, lock|
          # Not all deps were brought over (platform-specific ones) so weed them out
          lock[:dependencies] &= locks.keys
          lock[:development_dependencies] &= locks.keys

          lock[:dependencies] = transitive_dependencies(locks, name, :dependencies)
          lock[:development_dependencies] = transitive_dependencies(locks, name, :development_dependencies)
        end

        locks
      end
    end

    #
    # Get all desired gems, sans dependencies, from the gemfile.
    #
    # @param gemfile Path to the Gemfile to load
    #
    # @return Hash<String, Hash> An array of hashes where key = gem name and value
    #           has :groups (an array of symbols representing the groups the gem
    #           is in). :groups are not transitive, since we don't know the
    #           dependency tree yet.
    #
    def gem_declarations
      @gem_declarations ||= begin
        Bundler.with_clean_env do
          # Set BUNDLE_GEMFILE to the new gemfile temporarily so all bundler's things work
          # This works around some issues in bundler 1.11.2.
          ENV["BUNDLE_GEMFILE"] = gemfile_path

          parsed_gemfile = Bundler::Dsl.new
          parsed_gemfile.eval_gemfile(gemfile_path)
          parsed_gemfile.complete_overrides if parsed_gemfile.respond_to?(:complete_overrides)

          result = {}
          parsed_gemfile.dependencies.each do |dep|
            groups = dep.groups.empty? ? [:default] : dep.groups
            result[dep.name] = { groups: groups, platforms: dep.platforms }
          end
          result
        end
      end
    end

    private

    #
    # Given a bunch of locks (name -> { dependencies: [name,name] }) and a
    # dependency name, add its dependencies to the result transitively.
    #
    def transitive_dependencies(locks, name, dep_key, result = Set.new)
      locks[name][dep_key].each do |dep|
        # Only ever add a dep once, so we don't infinitely recurse
        if result.add?(dep)
          transitive_dependencies(locks, dep, dep_key, result)
        end
      end
      result
    end

    #
    # Get source and version metadata for the given Bundler spec (coming from a lockfile).
    #
    # @return Hash { version: <version>, git: <git>, path: <path>, source: <source>, ref: <ref> }
    #
    def lock_source_metadata(spec)
      # Copy source information from included Gemfile
      result = {}
      case spec.source
      when Bundler::Source::Rubygems
        result[:source] = spec.source.remotes.first.to_s
      when Bundler::Source::Git
        result[:git] = spec.source.uri.to_s
        result[:ref] = spec.source.revision
      when Bundler::Source::Path
        result[:path] = spec.source.path.to_s
      else
        raise "Unknown source #{spec.source} for gem #{spec.name}"
      end
      result
    end
  end
end