derailed_benchmarks 1.5.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +75 -0
  3. data/.github/workflows/check_changelog.yml +11 -8
  4. data/CHANGELOG.md +26 -1
  5. data/README.md +44 -11
  6. data/derailed_benchmarks.gemspec +7 -5
  7. data/gemfiles/rails_5_1.gemfile +3 -1
  8. data/gemfiles/rails_5_2.gemfile +3 -3
  9. data/gemfiles/rails_6_1.gemfile +13 -0
  10. data/gemfiles/rails_git.gemfile +2 -2
  11. data/lib/derailed_benchmarks.rb +4 -2
  12. data/lib/derailed_benchmarks/core_ext/kernel_require.rb +29 -24
  13. data/lib/derailed_benchmarks/git/commit.rb +36 -0
  14. data/lib/derailed_benchmarks/git/in_path.rb +59 -0
  15. data/lib/derailed_benchmarks/git/switch_project.rb +128 -0
  16. data/lib/derailed_benchmarks/git_switch_project.rb +1 -0
  17. data/lib/derailed_benchmarks/load_tasks.rb +11 -4
  18. data/lib/derailed_benchmarks/require_tree.rb +11 -1
  19. data/lib/derailed_benchmarks/{stats_in_file.rb → stats_for_file.rb} +8 -2
  20. data/lib/derailed_benchmarks/stats_from_dir.rb +53 -9
  21. data/lib/derailed_benchmarks/tasks.rb +32 -63
  22. data/lib/derailed_benchmarks/version.rb +1 -1
  23. data/test/derailed_benchmarks/core_ext/kernel_require_test.rb +70 -11
  24. data/test/derailed_benchmarks/git_switch_project_test.rb +83 -0
  25. data/test/derailed_benchmarks/require_tree_test.rb +1 -1
  26. data/test/derailed_benchmarks/stats_from_dir_test.rb +43 -10
  27. data/test/fixtures/require/autoload_child.rb +5 -0
  28. data/test/fixtures/require/autoload_parent.rb +8 -0
  29. data/test/fixtures/require/child_one.rb +1 -1
  30. data/test/fixtures/require/child_two.rb +1 -1
  31. data/test/fixtures/require/load_child.rb +3 -0
  32. data/test/fixtures/require/load_parent.rb +5 -0
  33. data/test/fixtures/require/parent_one.rb +1 -1
  34. data/test/integration/tasks_test.rb +45 -5
  35. data/test/rails_app/config/application.rb +2 -0
  36. data/test/rails_app/config/storage.yml +0 -0
  37. data/test/test_helper.rb +6 -1
  38. metadata +86 -30
  39. data/.travis.yml +0 -18
  40. data/Appraisals +0 -26
@@ -0,0 +1,59 @@
1
+ module DerailedBenchmarks
2
+ # A class for running commands in a git directory
3
+ #
4
+ # It's faster to check if we're already in that directory instead
5
+ # of having to `cd` into each time. https://twitter.com/schneems/status/1305196730170961920
6
+ #
7
+ # Example:
8
+ #
9
+ # in_git_path = InGitPath.new(`bundle info heapy --path`.strip)
10
+ # in_git_path.checkout!("f0f92b06156f2274021aa42f15326da041ee9009")
11
+ # in_git_path.short_sha # => "f0f92b0"
12
+ class Git::InPath
13
+ attr_reader :path
14
+
15
+ def initialize(path)
16
+ @path = path
17
+ end
18
+
19
+ def description
20
+ run!("git log --oneline --format=%B -n 1 HEAD | head -n 1")
21
+ end
22
+
23
+ def short_sha
24
+ run!("git rev-parse --short HEAD")
25
+ end
26
+
27
+ def time_stamp_string
28
+ run!("git log -n 1 --pretty=format:%ci") # https://stackoverflow.com/a/25921837/147390
29
+ end
30
+
31
+ def branch
32
+ branch = run!("git rev-parse --abbrev-ref HEAD")
33
+ branch == "HEAD" ? nil : branch
34
+ end
35
+
36
+ def checkout!(ref)
37
+ run!("git checkout '#{ref}' 2>&1")
38
+ end
39
+
40
+ def time
41
+ DateTime.parse(time_stamp_string)
42
+ end
43
+
44
+ def run(cmd)
45
+ if Dir.pwd == path
46
+ out = `#{cmd}`.strip
47
+ else
48
+ out = `cd #{path} && #{cmd}`.strip
49
+ end
50
+ out
51
+ end
52
+
53
+ def run!(cmd)
54
+ out = run(cmd)
55
+ raise "Error while running #{cmd.inspect}: #{out}" unless $?.success?
56
+ out
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,128 @@
1
+ module DerailedBenchmarks
2
+ class Git
3
+ end
4
+ end
5
+ require_relative "in_path.rb"
6
+ require_relative "commit.rb"
7
+
8
+ module DerailedBenchmarks
9
+ # Wraps two or more git commits in a specific location
10
+ #
11
+ # Returns an array of GitCommit objects that can be used to manipulate
12
+ # and checkout the repo
13
+ #
14
+ # Example:
15
+ #
16
+ # `git clone https://sharpstone/default_ruby tmp/default_ruby`
17
+ #
18
+ # project = GitSwitchProject.new(path: "tmp/default_ruby")
19
+ #
20
+ # By default it will represent the last two commits:
21
+ #
22
+ # project.commits.length # => 2
23
+ #
24
+ # You can pass in explicit REFs in an array:
25
+ #
26
+ # ref_array = ["da748a59340be8b950e7bbbfb32077eb67d70c3c", "9b19275a592f148e2a53b87ead4ccd8c747539c9"]
27
+ # project = GitSwitchProject.new(path: "tmp/default_ruby", ref_array: ref_array)
28
+ #
29
+ # puts project.commits.map(&:ref) == ref_array # => true
30
+ #
31
+ #
32
+ # It knows the current branch or sha:
33
+ #
34
+ # `cd tmp/ruby && git checkout -b mybranch`
35
+ # project.current_branch_or_sha #=> "mybranch"
36
+ #
37
+ # It can be used for safely wrapping checkouts to ensure the project returns to it's original branch:
38
+ #
39
+ # project.restore_branch_on_return do
40
+ # project.commits.first.checkout!
41
+ # project.current_branch_or_sha # => "da748a593"
42
+ # end
43
+ #
44
+ # project.current_branch_or_sha # => "mybranch"
45
+ class Git::SwitchProject
46
+ attr_reader :commits
47
+
48
+ def initialize(path: , ref_array: [], io: STDOUT, log_dir: "/dev/null")
49
+ @path = Pathname.new(path)
50
+
51
+ @in_git_path = Git::InPath.new(@path.expand_path)
52
+
53
+ raise "Must be a path with a .git directory '#{@path}'" if !@path.join(".git").exist?
54
+ @io = io
55
+ @commits = []
56
+ log_dir = Pathname(log_dir)
57
+
58
+ expand_refs(ref_array).each do |ref|
59
+ restore_branch_on_return(quiet: true) do
60
+ @commits << Git::Commit.new(path: @path, ref: ref, log_dir: log_dir)
61
+ end
62
+ end
63
+
64
+ if (duplicate = @commits.group_by(&:short_sha).detect {|(k, v)| v.length > 1})
65
+ raise "Duplicate SHA resolved #{duplicate[0].inspect}: #{duplicate[1].map {|c| "'#{c.ref}' => '#{c.short_sha}'"}.join(", ") } at #{@path}"
66
+ end
67
+ end
68
+
69
+ def current_branch_or_sha
70
+ branch_or_sha = @in_git_path.branch
71
+ branch_or_sha ||= @in_git_path.short_sha
72
+ branch_or_sha
73
+ end
74
+
75
+ def dirty?
76
+ !clean?
77
+ end
78
+
79
+ # https://stackoverflow.com/a/3879077/147390
80
+ def clean?
81
+ @in_git_path.run("git diff-index --quiet HEAD --") && $?.success?
82
+ end
83
+
84
+ private def status(pattern: "*.gemspec")
85
+ @in_git_path.run("git status #{pattern}")
86
+ end
87
+
88
+ def restore_branch_on_return(quiet: false)
89
+ if dirty? && status.include?("gemspec")
90
+ dirty_gemspec = true
91
+ unless quiet
92
+ @io.puts "Working tree at #{@path} is dirty, stashing. This will be popped on return"
93
+ @io.puts "Bundler modifies gemspec files on git install, this is normal"
94
+ @io.puts "Original status:\n#{status}"
95
+ end
96
+ @in_git_path.run!("git stash")
97
+ end
98
+ branch_or_sha = self.current_branch_or_sha
99
+ yield
100
+ ensure
101
+ return unless branch_or_sha
102
+ @io.puts "Resetting git dir of '#{@path.to_s}' to #{branch_or_sha.inspect}" unless quiet
103
+
104
+ @in_git_path.checkout!(branch_or_sha)
105
+ if dirty_gemspec
106
+ out = @in_git_path.run!("git stash apply 2>&1")
107
+ @io.puts "Applying stash of '#{@path.to_s}':\n#{out}" unless quiet
108
+ end
109
+ end
110
+
111
+ # case ref_array.length
112
+ # when >= 2
113
+ # returns original array
114
+ # when 1
115
+ # returns the given ref plus the one before it
116
+ # when 0
117
+ # returns the most recent 2 refs
118
+ private def expand_refs(ref_array)
119
+ return ref_array if ref_array.length >= 2
120
+
121
+ @in_git_path.checkout!(ref_array.first) if ref_array.first
122
+
123
+ branches_string = @in_git_path.run!("git log --format='%H' -n 2")
124
+ ref_array = branches_string.split($/)
125
+ return ref_array
126
+ end
127
+ end
128
+ end
@@ -30,7 +30,7 @@ namespace :perf do
30
30
  DERAILED_APP.initialize! unless DERAILED_APP.instance_variable_get(:@initialized)
31
31
  end
32
32
 
33
- if ENV["DERAILED_SKIP_ACTIVE_RECORD"] && defined? ActiveRecord
33
+ if !ENV["DERAILED_SKIP_ACTIVE_RECORD"] && defined? ActiveRecord
34
34
  if defined? ActiveRecord::Tasks::DatabaseTasks
35
35
  ActiveRecord::Tasks::DatabaseTasks.create_current
36
36
  else # Rails 3.2
@@ -39,7 +39,14 @@ namespace :perf do
39
39
 
40
40
  ActiveRecord::Migrator.migrations_paths = DERAILED_APP.paths['db/migrate'].to_a
41
41
  ActiveRecord::Migration.verbose = true
42
- ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, nil)
42
+
43
+ if Rails.version.start_with? '6'
44
+ ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths, ActiveRecord::SchemaMigration).migrate
45
+ elsif Rails.version.start_with? '5.2'
46
+ ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths).migrate
47
+ else
48
+ ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, nil)
49
+ end
43
50
  end
44
51
 
45
52
  DERAILED_APP.config.consider_all_requests_local = true
@@ -103,7 +110,7 @@ namespace :perf do
103
110
  STDERR.puts "Bad request to #{cmd.inspect} \n\n***RESPONSE***:\n\n#{ response.inspect }"
104
111
 
105
112
  FileUtils.mkdir_p("tmp")
106
- File.open("tmp/fail.html", "w+") {|f| f.write response.body }
113
+ File.open("tmp/fail.html", "w+") {|f| f.write response }
107
114
 
108
115
  `open #{File.expand_path("tmp/fail.html")}` if ENV["DERAILED_DEBUG"]
109
116
 
@@ -135,4 +142,4 @@ namespace :perf do
135
142
  WARM_COUNT.times { call_app }
136
143
  end
137
144
  end
138
- end
145
+ end
@@ -13,6 +13,16 @@ module DerailedBenchmarks
13
13
  def initialize(name)
14
14
  @name = name
15
15
  @children = {}
16
+ @cost = 0
17
+ end
18
+
19
+ def self.reset!
20
+ REQUIRED_BY.clear
21
+ if defined?(Kernel::REQUIRE_STACK)
22
+ Kernel::REQUIRE_STACK.clear
23
+
24
+ Kernel::REQUIRE_STACK.push(TOP_REQUIRE)
25
+ end
16
26
  end
17
27
 
18
28
  def <<(tree)
@@ -40,7 +50,7 @@ module DerailedBenchmarks
40
50
  end
41
51
 
42
52
  def to_string
43
- str = +"#{name}: #{cost.round(4)} MiB"
53
+ str = String.new("#{name}: #{cost.round(4)} MiB")
44
54
  if parent && REQUIRED_BY[self.name.to_s]
45
55
  names = REQUIRED_BY[self.name.to_s].uniq - [parent.name.to_s]
46
56
  if names.any?
@@ -16,24 +16,30 @@ module DerailedBenchmarks
16
16
  # x.average # => 10.5
17
17
  # x.name # => "muhfile"
18
18
  class StatsForFile
19
- attr_reader :name, :values, :desc, :time
19
+ attr_reader :name, :values, :desc, :time, :short_sha
20
20
 
21
- def initialize(file:, name:, desc: "", time: )
21
+ def initialize(file:, name:, desc: "", time: , short_sha: nil)
22
22
  @file = Pathname.new(file)
23
23
  FileUtils.touch(@file)
24
24
 
25
25
  @name = name
26
26
  @desc = desc
27
27
  @time = time
28
+ @short_sha = short_sha
28
29
  end
29
30
 
30
31
  def call
31
32
  load_file!
33
+ return if values.empty?
32
34
 
33
35
  @median = (values[(values.length - 1) / 2] + values[values.length/ 2]) / 2.0
34
36
  @average = values.inject(:+) / values.length
35
37
  end
36
38
 
39
+ def empty?
40
+ values.empty?
41
+ end
42
+
37
43
  def median
38
44
  @median.to_f
39
45
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require 'bigdecimal'
4
4
  require 'statistics'
5
+ require 'stringio'
6
+ require 'mini_histogram'
7
+ require 'mini_histogram/plot'
5
8
 
6
9
  module DerailedBenchmarks
7
10
  # A class used to read several benchmark files
@@ -26,14 +29,28 @@ module DerailedBenchmarks
26
29
  FORMAT = "%0.4f"
27
30
  attr_reader :stats, :oldest, :newest
28
31
 
29
- def initialize(hash)
32
+ def initialize(input)
30
33
  @files = []
31
34
 
32
- hash.each do |branch, info_hash|
33
- file = info_hash.fetch(:file)
34
- desc = info_hash.fetch(:desc)
35
- time = info_hash.fetch(:time)
36
- @files << StatsForFile.new(file: file, desc: desc, time: time, name: branch)
35
+ if input.is_a?(Hash)
36
+ hash = input
37
+ hash.each do |branch, info_hash|
38
+ file = info_hash.fetch(:file)
39
+ desc = info_hash.fetch(:desc)
40
+ time = info_hash.fetch(:time)
41
+ short_sha = info_hash[:short_sha]
42
+ @files << StatsForFile.new(file: file, desc: desc, time: time, name: branch, short_sha: short_sha)
43
+ end
44
+ else
45
+ input.each do |commit|
46
+ @files << StatsForFile.new(
47
+ file: commit.file,
48
+ desc: commit.desc,
49
+ time: commit.time,
50
+ name: commit.ref,
51
+ short_sha: commit.short_sha
52
+ )
53
+ end
37
54
  end
38
55
  @files.sort_by! { |f| f.time }
39
56
  @oldest = @files.first
@@ -43,6 +60,8 @@ module DerailedBenchmarks
43
60
  def call
44
61
  @files.each(&:call)
45
62
 
63
+ return self if @files.detect(&:empty?)
64
+
46
65
  stats_95 = statistical_test(confidence: 95)
47
66
 
48
67
  # If default check is good, see if we also pass a more rigorous test
@@ -100,7 +119,29 @@ module DerailedBenchmarks
100
119
  " " * (percent_faster.to_s.index(".") - x_faster.to_s.index("."))
101
120
  end
102
121
 
103
- def banner(io = Kernel)
122
+ def histogram(io = $stdout)
123
+ dual_histogram = MiniHistogram.dual_plot do |a, b|
124
+ a.values = newest.values
125
+ a.options = {
126
+ title: "\n [#{newest.short_sha || newest.name}] description:\n #{newest.desc.inspect}",
127
+ xlabel: "# of runs in range"
128
+ }
129
+ b.values = oldest.values
130
+ b.options = {
131
+ title: "\n [#{oldest.short_sha || oldest.name}] description:\n #{oldest.desc.inspect}",
132
+ xlabel: "# of runs in range"
133
+ }
134
+ end
135
+
136
+ io.puts
137
+ io.puts "Histograms (time ranges are in seconds):"
138
+ io.puts(dual_histogram)
139
+ io.puts
140
+ end
141
+
142
+ def banner(io = $stdout)
143
+ return if @files.detect(&:empty?)
144
+
104
145
  io.puts
105
146
  if significant?
106
147
  io.puts "❤️ ❤️ ❤️ (Statistically Significant) ❤️ ❤️ ❤️"
@@ -108,11 +149,11 @@ module DerailedBenchmarks
108
149
  io.puts "👎👎👎(NOT Statistically Significant) 👎👎👎"
109
150
  end
110
151
  io.puts
111
- io.puts "[#{newest.name}] #{newest.desc.inspect} - (#{newest.median} seconds)"
152
+ io.puts "[#{newest.short_sha || newest.name}] (#{FORMAT % newest.median} seconds) #{newest.desc.inspect} ref: #{newest.name.inspect}"
112
153
  io.puts " #{change_direction} by:"
113
154
  io.puts " #{align}#{FORMAT % x_faster}x [older/newer]"
114
155
  io.puts " #{FORMAT % percent_faster}\% [(older - newer) / older * 100]"
115
- io.puts "[#{oldest.name}] #{oldest.desc.inspect} - (#{oldest.median} seconds)"
156
+ io.puts "[#{oldest.short_sha || oldest.name}] (#{FORMAT % oldest.median} seconds) #{oldest.desc.inspect} ref: #{oldest.name.inspect}"
116
157
  io.puts
117
158
  io.puts "Iterations per sample: #{ENV["TEST_COUNT"]}"
118
159
  io.puts "Samples: #{newest.values.length}"
@@ -122,6 +163,9 @@ module DerailedBenchmarks
122
163
  io.puts "Is significant? (max > critical): #{significant?}"
123
164
  io.puts "D critical: #{d_critical}"
124
165
  io.puts "D max: #{d_max}"
166
+
167
+ histogram(io)
168
+
125
169
  io.puts
126
170
  end
127
171
  end
@@ -1,6 +1,12 @@
1
1
  require_relative 'load_tasks'
2
2
 
3
3
  namespace :perf do
4
+ desc "runs the performance test against two most recent commits of the current app"
5
+ task :app do
6
+ ENV["DERAILED_PATH_TO_LIBRARY"] = '.'
7
+ Rake::Task["perf:library"].invoke
8
+ end
9
+
4
10
  desc "runs the same test against two different branches for statistical comparison"
5
11
  task :library do
6
12
  begin
@@ -11,94 +17,57 @@ namespace :perf do
11
17
  script = ENV["DERAILED_SCRIPT"] || "bundle exec derailed exec perf:test"
12
18
 
13
19
  if ENV["DERAILED_PATH_TO_LIBRARY"]
14
- library_dir = ENV["DERAILED_PATH_TO_LIBRARY"]
20
+ library_dir = ENV["DERAILED_PATH_TO_LIBRARY"].chomp
15
21
  else
16
22
  library_dir = DerailedBenchmarks.rails_path_on_disk
17
23
  end
18
-
19
- raise "Must be a path with a .git directory '#{library_dir}'" unless File.exist?(File.join(library_dir, ".git"))
20
-
21
- # Use either the explicit SHAs when present or grab last two SHAs from commit history
22
- # if only one SHA is given, then use it and the last SHA from commit history
23
- branch_names = []
24
- branch_names = ENV.fetch("SHAS_TO_TEST").split(",") if ENV["SHAS_TO_TEST"]
25
- if branch_names.length < 2
26
- Dir.chdir(library_dir) do
27
- run!("git checkout '#{branch_names.first}'") unless branch_names.empty?
28
-
29
- branches = run!('git log --format="%H" -n 2').chomp.split($/)
30
- if branch_names.empty?
31
- branch_names = branches
32
- else
33
- branches.shift
34
- branch_names << branches.shift
35
- end
36
- end
37
- end
38
-
39
- current_library_branch = ""
40
- Dir.chdir(library_dir) { current_library_branch = run!('git describe --contains --all HEAD').chomp }
24
+ library_dir = Pathname.new(library_dir)
41
25
 
42
26
  out_dir = Pathname.new("tmp/compare_branches/#{Time.now.strftime('%Y-%m-%d-%H-%M-%s-%N')}")
43
27
  out_dir.mkpath
44
28
 
45
- branches_to_test = branch_names.each_with_object({}) {|elem, hash| hash[elem] = out_dir + "#{elem.gsub('/', ':')}.bench.txt" }
46
- branch_info = {}
47
- branch_to_sha = {}
29
+ ref_string = ENV["SHAS_TO_TEST"] || ENV["REFS_TO_TEST"] || ""
48
30
 
49
- branches_to_test.each do |branch, file|
50
- Dir.chdir(library_dir) do
51
- run!("git checkout '#{branch}'")
52
- description = run!("git log --oneline --format=%B -n 1 HEAD | head -n 1").strip
53
- time_stamp = run!("git log -n 1 --pretty=format:%ci").strip # https://stackoverflow.com/a/25921837/147390
54
- short_sha = run!("git rev-parse --short HEAD").strip
55
- branch_to_sha[branch] = short_sha
31
+ project = DerailedBenchmarks::Git::SwitchProject.new(
32
+ path: library_dir,
33
+ ref_array: ref_string.split(","),
34
+ log_dir: out_dir
35
+ )
56
36
 
57
- branch_info[short_sha] = { desc: description, time: DateTime.parse(time_stamp), file: file }
58
- end
59
- run!("#{script}")
60
- end
37
+ stats = DerailedBenchmarks::StatsFromDir.new(project.commits)
61
38
 
39
+ # Advertise branch names early to make sure people know what they're testing
62
40
  puts
63
41
  puts
64
- branches_to_test.each.with_index do |(branch, _), i|
65
- short_sha = branch_to_sha[branch]
66
- desc = branch_info[short_sha][:desc]
67
- puts "Testing #{i + 1}: #{short_sha}: #{desc}"
42
+ project.commits.each_with_index do |commit, i|
43
+ puts "Testing #{i + 1}: #{commit.short_sha}: #{commit.description}"
68
44
  end
69
45
  puts
70
46
  puts
71
47
 
72
- raise "SHAs to test must be different" if branch_info.length == 1
73
- stats = DerailedBenchmarks::StatsFromDir.new(branch_info)
74
- puts "Env var no longer has any affect DERAILED_STOP_VALID_COUNT" if ENV["DERAILED_STOP_VALID_COUNT"]
48
+ project.restore_branch_on_return do
49
+ DERAILED_SCRIPT_COUNT.times do |i|
50
+ puts "Sample: #{i.next}/#{DERAILED_SCRIPT_COUNT} iterations per sample: #{ENV['TEST_COUNT']}"
51
+ project.commits.each do |commit|
52
+ commit.checkout!
75
53
 
76
- DERAILED_SCRIPT_COUNT.times do |i|
77
- puts "Sample: #{i.next}/#{DERAILED_SCRIPT_COUNT} iterations per sample: #{ENV['TEST_COUNT']}"
78
- branches_to_test.each do |branch, file|
79
- Dir.chdir(library_dir) { run!("git checkout '#{branch}'") }
80
- run!(" #{script} 2>&1 | tail -n 1 >> '#{file}'")
81
- end
54
+ output = run!("#{script} 2>&1")
55
+ commit.log.open("a") {|f| f.puts output.lines.last }
56
+ end
82
57
 
83
- if (i % 50).zero?
84
- puts "Intermediate result"
85
- stats.call.banner
86
- puts "Continuing execution"
58
+ if (i % 50).zero?
59
+ puts "Intermediate result"
60
+ stats.call.banner
61
+ puts "Continuing execution"
62
+ end
87
63
  end
88
64
  end
89
65
 
90
66
  ensure
91
- if library_dir && current_library_branch
92
- puts "Resetting git dir of '#{library_dir.to_s}' to #{current_library_branch.inspect}"
93
- Dir.chdir(library_dir) do
94
- run!("git checkout '#{current_library_branch}'")
95
- end
96
- end
97
-
98
67
  if stats
99
68
  stats.call.banner
100
69
 
101
- result_file = out_dir + "results.txt"
70
+ result_file = out_dir.join("results.txt")
102
71
  File.open(result_file, "w") do |f|
103
72
  stats.banner(f)
104
73
  end