aboutsummaryrefslogtreecommitdiffstats
path: root/Library/Homebrew/cmd/update-report.rb
blob: fa82c875d46c475c0542e12468c84652a86c0e68 (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
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
#: @hide_from_man_page
#:  * `update_report`:
#:    The Ruby implementation of `brew update`. Never called manually.

require "formula_versions"
require "migrator"
require "formulary"
require "descriptions"
require "cleanup"

module Homebrew
  def update_preinstall_header
    @header_already_printed ||= begin
      ohai "Auto-updated Homebrew!" if ARGV.include?("--preinstall")
      true
    end
  end

  def update_report
    HOMEBREW_REPOSITORY.cd do
      analytics_message_displayed = \
        Utils.popen_read("git", "config", "--local", "--get", "homebrew.analyticsmessage").chuzzle
      analytics_disabled = \
        Utils.popen_read("git", "config", "--local", "--get", "homebrew.analyticsdisabled").chuzzle
      if analytics_message_displayed != "true" && analytics_disabled != "true" && !ENV["HOMEBREW_NO_ANALYTICS"]
        ENV["HOMEBREW_NO_ANALYTICS_THIS_RUN"] = "1"
        ohai "Homebrew has enabled anonymous aggregate user behaviour analytics"
        puts "Read the analytics documentation (and how to opt-out) here:"
        puts "  https://git.io/brew-analytics"

        # Consider the message possibly missed if not a TTY.
        if $stdout.tty?
          safe_system "git", "config", "--local", "--replace-all", "homebrew.analyticsmessage", "true"
        end
      end
    end

    install_core_tap_if_necessary

    hub = ReporterHub.new
    updated = false

    initial_revision = ENV["HOMEBREW_UPDATE_BEFORE"].to_s
    current_revision = ENV["HOMEBREW_UPDATE_AFTER"].to_s
    if initial_revision.empty? || current_revision.empty?
      odie "update-report should not be called directly!"
    end

    if initial_revision != current_revision
      update_preinstall_header
      puts "Updated Homebrew from #{shorten_revision(initial_revision)} to #{shorten_revision(current_revision)}."
      updated = true
    end

    updated_taps = []
    Tap.each do |tap|
      next unless tap.git?
      begin
        reporter = Reporter.new(tap)
      rescue Reporter::ReporterRevisionUnsetError => e
        onoe "#{e.message}\n#{e.backtrace.join "\n"}" if ARGV.homebrew_developer?
        next
      end
      if reporter.updated?
        updated_taps << tap.name
        hub.add(reporter)
      end
    end

    unless updated_taps.empty?
      update_preinstall_header
      puts "Updated #{updated_taps.size} tap#{plural(updated_taps.size)} " \
           "(#{updated_taps.join(", ")})."
      updated = true
    end

    migrate_legacy_cache_if_necessary
    migrate_legacy_keg_symlinks_if_necessary

    if !updated
      if !ARGV.include?("--preinstall") && !ENV["HOMEBREW_UPDATE_FAILED"]
        puts "Already up-to-date."
      end
    else
      if hub.empty?
        puts "No changes to formulae."
      else
        hub.dump
        hub.reporters.each(&:migrate_tap_migration)
        hub.reporters.each(&:migrate_formula_rename)
        Descriptions.update_cache(hub)
      end
      puts if ARGV.include?("--preinstall")
    end

    link_completions_and_docs
    Tap.each(&:link_manpages)

    Homebrew.failed = true if ENV["HOMEBREW_UPDATE_FAILED"]
  end

  private

  def shorten_revision(revision)
    Utils.popen_read("git", "-C", HOMEBREW_REPOSITORY, "rev-parse", "--short", revision).chomp
  end

  def install_core_tap_if_necessary
    core_tap = CoreTap.instance
    return if core_tap.installed?
    CoreTap.ensure_installed! :quiet => false
    revision = core_tap.git_head
    ENV["HOMEBREW_UPDATE_BEFORE_HOMEBREW_HOMEBREW_CORE"] = revision
    ENV["HOMEBREW_UPDATE_AFTER_HOMEBREW_HOMEBREW_CORE"] = revision
  end

  def migrate_legacy_cache_if_necessary
    legacy_cache = Pathname.new "/Library/Caches/Homebrew"
    return if HOMEBREW_CACHE.to_s == legacy_cache.to_s
    return unless legacy_cache.directory?
    return unless legacy_cache.readable_real?

    migration_attempted_file = legacy_cache/".migration_attempted"
    return if migration_attempted_file.exist?

    return unless legacy_cache.writable_real?
    FileUtils.touch migration_attempted_file

    # Cleanup to avoid copying files unnecessarily
    ohai "Cleaning up #{legacy_cache}..."
    Cleanup.cleanup_cache legacy_cache

    # This directory could have been compromised if it's world-writable/
    # a symlink/owned by another user so don't copy files in those cases.
    world_writable = legacy_cache.stat.mode & 0777 == 0777
    return if world_writable
    return if legacy_cache.symlink?
    return if !legacy_cache.owned? && legacy_cache.lstat.uid.nonzero?

    ohai "Migrating #{legacy_cache} to #{HOMEBREW_CACHE}..."
    HOMEBREW_CACHE.mkpath
    legacy_cache.cd do
      legacy_cache.entries.each do |f|
        next if [".", "..", ".migration_attempted"].include? f.to_s
        begin
          FileUtils.cp_r f, HOMEBREW_CACHE
        rescue
          @migration_failed ||= true
        end
      end
    end

    if @migration_failed
      opoo <<-EOS.undent
        Failed to migrate #{legacy_cache} to
        #{HOMEBREW_CACHE}. Please do so manually.
      EOS
    else
      ohai "Deleting #{legacy_cache}..."
      FileUtils.rm_rf legacy_cache
      if legacy_cache.exist?
        FileUtils.touch migration_attempted_file
        opoo <<-EOS.undent
          Failed to delete #{legacy_cache}.
          Please do so manually.
        EOS
      end
    end
  end

  def migrate_legacy_keg_symlinks_if_necessary
    legacy_linked_kegs = HOMEBREW_LIBRARY/"LinkedKegs"
    return unless legacy_linked_kegs.directory?

    legacy_linked_kegs.children.each {|f| Keg.new(f.realpath).link }
    FileUtils.rm_rf legacy_linked_kegs

    legacy_pinned_kegs = HOMEBREW_LIBRARY/"PinnedKegs"
    return unless legacy_pinned_kegs.directory?

    legacy_pinned_kegs.children.each do |f|
      pin_version = Keg.new(f.realpath).version
      formula = Formulary.factory(f.basename.to_s)
      FormulaPin.new(formula).pin_at(pin_version)
    end
    FileUtils.rm_rf legacy_pinned_kegs
  end

  def link_completions_and_docs
    return if HOMEBREW_PREFIX.to_s == HOMEBREW_REPOSITORY.to_s
    command = "brew update"
    link_src_dst_dirs(HOMEBREW_REPOSITORY/"etc/bash_completion.d",
                      HOMEBREW_PREFIX/"etc/bash_completion.d", command)
    link_src_dst_dirs(HOMEBREW_REPOSITORY/"share/doc/homebrew",
                      HOMEBREW_PREFIX/"share/doc/homebrew", command, :link_dir => true)
    link_src_dst_dirs(HOMEBREW_REPOSITORY/"share/zsh/site-functions",
                      HOMEBREW_PREFIX/"share/zsh/site-functions", command)
    link_path_manpages(HOMEBREW_REPOSITORY/"share", command)
  end
end

class Reporter
  class ReporterRevisionUnsetError < RuntimeError
    def initialize(var_name)
      super "#{var_name} is unset!"
    end
  end

  attr_reader :tap, :initial_revision, :current_revision

  def initialize(tap)
    @tap = tap

    initial_revision_var = "HOMEBREW_UPDATE_BEFORE#{repo_var}"
    @initial_revision = ENV[initial_revision_var].to_s
    raise ReporterRevisionUnsetError, initial_revision_var if @initial_revision.empty?

    current_revision_var = "HOMEBREW_UPDATE_AFTER#{repo_var}"
    @current_revision = ENV[current_revision_var].to_s
    raise ReporterRevisionUnsetError, current_revision_var if @current_revision.empty?
  end

  def report
    return @report if @report

    @report = Hash.new { |h, k| h[k] = [] }
    return @report unless updated?

    diff.each_line do |line|
      status, *paths = line.split
      src = Pathname.new paths.first
      dst = Pathname.new paths.last

      next unless dst.extname == ".rb"

      if paths.any? { |p| tap.cask_file?(p) }
        # Currently only need to handle Cask deletion/migration.
        if status == "D"
          # Have a dedicated report array for deleted casks.
          @report[:DC] << tap.formula_file_to_name(src)
        end
      end

      next unless paths.any? { |p| tap.formula_file?(p) }

      case status
      when "A", "D"
        @report[status.to_sym] << tap.formula_file_to_name(src)
      when "M"
        begin
          formula = Formulary.factory(tap.path/src)
          new_version = formula.pkg_version
          old_version = FormulaVersions.new(formula).formula_at_revision(@initial_revision, &:pkg_version)
          next if new_version == old_version
        rescue Exception => e
          onoe "#{e.message}\n#{e.backtrace.join "\n"}" if ARGV.homebrew_developer?
        end
        @report[:M] << tap.formula_file_to_name(src)
      when /^R\d{0,3}/
        src_full_name = tap.formula_file_to_name(src)
        dst_full_name = tap.formula_file_to_name(dst)
        # Don't report formulae that are moved within a tap but not renamed
        next if src_full_name == dst_full_name
        @report[:D] << src_full_name
        @report[:A] << dst_full_name
      end
    end

    renamed_formulae = []
    @report[:D].each do |old_full_name|
      old_name = old_full_name.split("/").last
      new_name = tap.formula_renames[old_name]
      next unless new_name

      if tap.core_tap?
        new_full_name = new_name
      else
        new_full_name = "#{tap}/#{new_name}"
      end

      renamed_formulae << [old_full_name, new_full_name] if @report[:A].include? new_full_name
    end

    unless renamed_formulae.empty?
      @report[:A] -= renamed_formulae.map(&:last)
      @report[:D] -= renamed_formulae.map(&:first)
      @report[:R] = renamed_formulae
    end

    @report
  end

  def updated?
    initial_revision != current_revision
  end

  def migrate_tap_migration
    (report[:D] + report[:DC]).each do |full_name|
      name = full_name.split("/").last
      new_tap_name = tap.tap_migrations[name]
      next if new_tap_name.nil? # skip if not in tap_migrations list.

      # This means it is a Cask
      if report[:DC].include? full_name
        next unless (HOMEBREW_REPOSITORY/"Caskroom"/name).exist?
        new_tap = Tap.fetch(new_tap_name)
        new_tap.install unless new_tap.installed?
        ohai "#{name} has been moved to Homebrew.", <<-EOS.undent
          To uninstall the cask run:
            brew cask uninstall --force #{name}
        EOS
        new_full_name = "#{new_tap_name}/#{name}"
        next if (HOMEBREW_CELLAR/name.split("/").last).directory?
        ohai "Installing #{name}..."
        system HOMEBREW_BREW_FILE, "install", new_full_name
        begin
          unless Formulary.factory(new_full_name).keg_only?
            system HOMEBREW_BREW_FILE, "link", new_full_name, "--overwrite"
          end
        rescue Exception => e
          onoe "#{e.message}\n#{e.backtrace.join "\n"}" if ARGV.homebrew_developer?
        end
        next
      end

      next unless (dir = HOMEBREW_CELLAR/name).exist? # skip if formula is not installed.
      tabs = dir.subdirs.map { |d| Tab.for_keg(Keg.new(d)) }
      next unless tabs.first.tap == tap # skip if installed formula is not from this tap.
      new_tap = Tap.fetch(new_tap_name)
      # For formulae migrated to cask: Auto-install cask or provide install instructions.
      if new_tap_name == "caskroom/cask"
        if new_tap.installed? && (HOMEBREW_REPOSITORY/"Caskroom").directory?
          ohai "#{name} has been moved to Homebrew Cask."
          ohai "brew uninstall --force #{name}"
          system HOMEBREW_BREW_FILE, "uninstall", "--force", name
          ohai "brew prune"
          system HOMEBREW_BREW_FILE, "prune"
          ohai "brew cask install #{name}"
          system HOMEBREW_BREW_FILE, "cask", "install", name
        else
          ohai "#{name} has been moved to Homebrew Cask.", <<-EOS.undent
            To uninstall the formula and install the cask run:
              brew uninstall --force #{name}
              brew cask install #{name}
          EOS
        end
      else
        new_tap.install unless new_tap.installed?
        # update tap for each Tab
        tabs.each { |tab| tab.tap = new_tap }
        tabs.each(&:write)
      end
    end
  end

  def migrate_formula_rename
    report[:R].each do |old_full_name, new_full_name|
      old_name = old_full_name.split("/").last
      next unless (dir = HOMEBREW_CELLAR/old_name).directory? && !dir.subdirs.empty?

      begin
        f = Formulary.factory(new_full_name)
      rescue Exception => e
        onoe "#{e.message}\n#{e.backtrace.join "\n"}" if ARGV.homebrew_developer?
        next
      end

      begin
        migrator = Migrator.new(f)
        migrator.migrate
      rescue Migrator::MigratorDifferentTapsError
      rescue Exception => e
        onoe e
      end
    end
  end

  private

  def repo_var
    @repo_var ||= tap.path.to_s
                     .strip_prefix(Tap::TAP_DIRECTORY.to_s)
                     .tr("^A-Za-z0-9", "_")
                     .upcase
  end

  def diff
    Utils.popen_read(
      "git", "-C", tap.path, "diff-tree", "-r", "--name-status", "--diff-filter=AMDR",
      "-M85%", initial_revision, current_revision
    )
  end
end

class ReporterHub
  attr_reader :reporters

  def initialize
    @hash = {}
    @reporters = []
  end

  def select_formula(key)
    @hash.fetch(key, [])
  end

  def add(reporter)
    @reporters << reporter
    report = reporter.report.delete_if { |_k, v| v.empty? }
    @hash.update(report) { |_key, oldval, newval| oldval.concat(newval) }
  end

  def empty?
    @hash.empty?
  end

  def dump
    # Key Legend: Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R)

    dump_formula_report :A, "New Formulae"
    dump_formula_report :M, "Updated Formulae"
    dump_formula_report :R, "Renamed Formulae"
    dump_formula_report :D, "Deleted Formulae"
  end

  private

  def dump_formula_report(key, title)
    formulae = select_formula(key).sort.map do |name, new_name|
      # Format list items of renamed formulae
      if key == :R
        name = pretty_installed(name) if installed?(name)
        new_name = pretty_installed(new_name) if installed?(new_name)
        "#{name} -> #{new_name}"
      else
        installed?(name) ? pretty_installed(name) : name
      end
    end

    unless formulae.empty?
      # Dump formula list.
      ohai title
      puts_columns(formulae)
    end
  end

  def installed?(formula)
    (HOMEBREW_CELLAR/formula.split("/").last).directory?
  end
end