duel 0.1.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.
- checksums.yaml +7 -0
- data/AGENT_PROMPT.md +211 -0
- data/CHANGELOG.md +281 -0
- data/LICENSE +21 -0
- data/README.md +354 -0
- data/docs/README.md +23 -0
- data/docs/benchmarks.md +289 -0
- data/docs/ci.md +141 -0
- data/docs/configuration.md +429 -0
- data/docs/how-it-works.md +207 -0
- data/docs/json.md +306 -0
- data/docs/plugins.md +408 -0
- data/docs/troubleshooting.md +105 -0
- data/exe/duel +6 -0
- data/lib/duel/across.rb +48 -0
- data/lib/duel/baseline.rb +166 -0
- data/lib/duel/cli.rb +264 -0
- data/lib/duel/config.rb +343 -0
- data/lib/duel/context.rb +630 -0
- data/lib/duel/init.rb +211 -0
- data/lib/duel/json_report.rb +107 -0
- data/lib/duel/lock.rb +38 -0
- data/lib/duel/metric.rb +496 -0
- data/lib/duel/native.rb +162 -0
- data/lib/duel/process_sample.rb +144 -0
- data/lib/duel/profile.rb +116 -0
- data/lib/duel/rake_task.rb +25 -0
- data/lib/duel/resource.rb +209 -0
- data/lib/duel/rubies.rb +149 -0
- data/lib/duel/runner.rb +1114 -0
- data/lib/duel/stats.rb +73 -0
- data/lib/duel/summary.rb +237 -0
- data/lib/duel/templates/benchmark_duel.rb.erb +34 -0
- data/lib/duel/templates/duel.rb.erb +35 -0
- data/lib/duel/templates/perf.yml.erb +97 -0
- data/lib/duel/testing.rb +241 -0
- data/lib/duel/version.rb +5 -0
- data/lib/duel/worker.rb +211 -0
- data/lib/duel.rb +413 -0
- data/sig/duel/across.rbs +21 -0
- data/sig/duel/baseline.rbs +61 -0
- data/sig/duel/cli.rbs +95 -0
- data/sig/duel/config.rbs +181 -0
- data/sig/duel/context.rbs +387 -0
- data/sig/duel/init.rbs +97 -0
- data/sig/duel/json_report.rbs +47 -0
- data/sig/duel/lock.rbs +12 -0
- data/sig/duel/metric.rbs +351 -0
- data/sig/duel/native.rbs +98 -0
- data/sig/duel/process_sample.rbs +54 -0
- data/sig/duel/profile.rbs +64 -0
- data/sig/duel/rake_task.rbs +9 -0
- data/sig/duel/resource.rbs +106 -0
- data/sig/duel/rubies.rbs +45 -0
- data/sig/duel/runner.rbs +530 -0
- data/sig/duel/stats.rbs +26 -0
- data/sig/duel/summary.rbs +87 -0
- data/sig/duel/testing.rbs +91 -0
- data/sig/duel/version.rbs +3 -0
- data/sig/duel/worker.rbs +74 -0
- data/sig/duel.rbs +276 -0
- data/sig/manifest.yaml +4 -0
- metadata +124 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
require_relative "../duel"
|
|
7
|
+
|
|
8
|
+
module Duel
|
|
9
|
+
# The version to compare against: a directory (Settings#base_dir), or a
|
|
10
|
+
# git commit checked out (and built, see Config#build) where #checkout
|
|
11
|
+
# says. Settings#base names the commit:
|
|
12
|
+
#
|
|
13
|
+
# - nil: where HEAD left the main branch (their merge base):
|
|
14
|
+
# Config#main_branch, or the one origin/HEAD names, or origin/main, or
|
|
15
|
+
# origin/master, or (a repository without a remote) main, or master
|
|
16
|
+
# - "last-release": the newest release tag (Config#release_tags) before
|
|
17
|
+
# HEAD, to catch slowdowns too small for any one change to fail but
|
|
18
|
+
# that add up between releases
|
|
19
|
+
# - anything else: a git ref
|
|
20
|
+
# @api private
|
|
21
|
+
class Baseline
|
|
22
|
+
LAST_RELEASE = "last-release"
|
|
23
|
+
# The main branch's usual names, origin's first (see #main_branch).
|
|
24
|
+
BRANCHES = %w[origin/main origin/master main master].freeze
|
|
25
|
+
|
|
26
|
+
def initialize(config, settings)
|
|
27
|
+
@config = config
|
|
28
|
+
@settings = settings
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The commit, or nil for "last-release" with no release yet.
|
|
32
|
+
def ref
|
|
33
|
+
return @ref if instance_variable_defined?(:@ref)
|
|
34
|
+
|
|
35
|
+
@ref = case @settings.base
|
|
36
|
+
when nil then merge_base
|
|
37
|
+
when LAST_RELEASE then git("describe", "--tags", "--abbrev=0", "--match", @config.release_tags, "HEAD^", allow_failure: true)
|
|
38
|
+
else @settings.base
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether nothing Config#watch names changed since the baseline (only
|
|
43
|
+
# for a git baseline compared with the working tree).
|
|
44
|
+
def unchanged?
|
|
45
|
+
return false if @settings.base_dir || @settings.candidate_dir
|
|
46
|
+
|
|
47
|
+
# (There's a ref: CLI#run checks first.)
|
|
48
|
+
commit = ref #: String
|
|
49
|
+
_, status = Open3.capture2e("git", "-C", @config.root, "diff", "--quiet", commit, "--", *@config.watch)
|
|
50
|
+
status.success?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The baseline's tree, checked out and built if it's a commit.
|
|
54
|
+
def root
|
|
55
|
+
base_dir = @settings.base_dir
|
|
56
|
+
return File.expand_path(base_dir) if base_dir
|
|
57
|
+
|
|
58
|
+
sha = git("rev-parse", "#{ref}^{commit}")
|
|
59
|
+
dir = checkout
|
|
60
|
+
unless git("-C", dir, "rev-parse", "HEAD", allow_failure: true) == sha
|
|
61
|
+
FileUtils.rm_rf(dir)
|
|
62
|
+
git("worktree", "prune") # (git's record of it)
|
|
63
|
+
git("worktree", "add", dir, sha)
|
|
64
|
+
build(within(dir), "the baseline") if Dir.exist?(within(dir))
|
|
65
|
+
end
|
|
66
|
+
raise Error, "the baseline (#{ref}) has no #{prefix}: compare with --base REF, a commit that has it" unless Dir.exist?(within(dir))
|
|
67
|
+
|
|
68
|
+
within(dir)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Where the project is in a checkout of its repository at +dir+: as far
|
|
72
|
+
# from the top as it is in its own (a project in a subdirectory of a
|
|
73
|
+
# monorepo, say).
|
|
74
|
+
def within(dir) = prefix.empty? ? dir : File.join(dir, prefix)
|
|
75
|
+
|
|
76
|
+
# The project's path from its repository's top level ("" at the top,
|
|
77
|
+
# and outside a repository).
|
|
78
|
+
def prefix = @prefix ||= git("rev-parse", "--show-prefix", allow_failure: true).to_s.chomp("/")
|
|
79
|
+
|
|
80
|
+
# Where a git baseline is checked out (the whole repository, whose
|
|
81
|
+
# #within is the project): next to the repository's top level, under a
|
|
82
|
+
# hidden name as long as its own, from the project's path's hash
|
|
83
|
+
# (".3f9c2a1b7", next to duel-rails), so the two versions' paths (with
|
|
84
|
+
# symlinks resolved, as Rails resolves its root) are alike: as long,
|
|
85
|
+
# and as deep. What a version does with its own paths (looking for a
|
|
86
|
+
# file a request might be for, say) takes longer on a longer or a
|
|
87
|
+
# deeper one, and a baseline that measured slower would hide a
|
|
88
|
+
# regression that small. (A repository named with one letter has a
|
|
89
|
+
# name of two.) Where that can't be (a directory Duel can't write in,
|
|
90
|
+
# or something else of that name there), tmp/duel/base.
|
|
91
|
+
def checkout
|
|
92
|
+
root = File.realpath(@config.root)
|
|
93
|
+
top = root.delete_suffix("/#{prefix}")
|
|
94
|
+
length = [File.basename(top).length - 1, 1].max
|
|
95
|
+
sibling = File.join(File.dirname(top), ".#{Digest::SHA256.hexdigest(root).ljust(length, "0")[0, length]}")
|
|
96
|
+
return sibling if File.writable?(File.dirname(top)) && (!File.exist?(sibling) || ours?(sibling))
|
|
97
|
+
|
|
98
|
+
File.join(@config.root, "tmp", "duel", "base")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Removes what Duel keeps: tmp/duel, and the baseline's checkout (and
|
|
102
|
+
# git's record of it as a worktree).
|
|
103
|
+
def clean
|
|
104
|
+
FileUtils.rm_rf(File.join(@config.root, "tmp", "duel"))
|
|
105
|
+
FileUtils.rm_rf(checkout)
|
|
106
|
+
git("worktree", "prune", allow_failure: true) # (not a git repository, say)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The main branch (see #ref).
|
|
110
|
+
def main_branch
|
|
111
|
+
@config.main_branch ||
|
|
112
|
+
git("symbolic-ref", "--short", "refs/remotes/origin/HEAD", allow_failure: true) ||
|
|
113
|
+
BRANCHES.find { |branch| git("rev-parse", branch, allow_failure: true) } ||
|
|
114
|
+
raise(Error, "no main branch to compare with (set c.main_branch in .duel.rb, or --base)")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Builds the tree in +dir+ (Config#build), if there's a build: the
|
|
118
|
+
# baseline's when it's checked out, and the candidate's before every
|
|
119
|
+
# run, so a stale or missing build (a C extension the code quietly
|
|
120
|
+
# does without, say) never decides a comparison. Builds are
|
|
121
|
+
# incremental, so one already done costs little.
|
|
122
|
+
def build(dir, what)
|
|
123
|
+
command = @config.build
|
|
124
|
+
return unless command
|
|
125
|
+
|
|
126
|
+
output, status = Open3.capture2e(command, chdir: dir)
|
|
127
|
+
raise Error, "building #{what} (#{command}) failed:\n#{output}" unless status.success?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Where HEAD left the main branch: their merge base, which there isn't
|
|
133
|
+
# when their histories are unrelated, or in a shallow clone (CI's,
|
|
134
|
+
# fetched without the history), where git fails saying nothing.
|
|
135
|
+
def merge_base
|
|
136
|
+
unless git("rev-parse", "HEAD", allow_failure: true)
|
|
137
|
+
raise Error, "this repository has no commits yet, so there's nothing to compare with: commit, " \
|
|
138
|
+
"or compare with a directory (--base DIR)"
|
|
139
|
+
end
|
|
140
|
+
branch = main_branch
|
|
141
|
+
out, err, status = Open3.capture3("git", "-C", @config.root, "merge-base", "HEAD", branch)
|
|
142
|
+
return out.chomp if status.success?
|
|
143
|
+
raise Error, "git merge-base HEAD #{branch} failed: #{err.chomp}" unless err.empty?
|
|
144
|
+
|
|
145
|
+
why = if git("rev-parse", "--is-shallow-repository") == "true"
|
|
146
|
+
"this is a shallow clone. Fetch the whole history (on GitHub Actions, fetch-depth: 0 for actions/checkout)"
|
|
147
|
+
else
|
|
148
|
+
"their histories are unrelated. Set c.main_branch in .duel.rb"
|
|
149
|
+
end
|
|
150
|
+
raise Error, "HEAD and #{branch} have no commit in common: #{why}, or compare with --base REF"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Whether +dir+ is a checkout of this repository (a worktree of it).
|
|
154
|
+
def ours?(dir) = common_dir(dir) == common_dir(@config.root)
|
|
155
|
+
|
|
156
|
+
# The repository a directory is a checkout of (its git directory), or
|
|
157
|
+
# nil for none.
|
|
158
|
+
def common_dir(dir) = git("-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir", allow_failure: true)
|
|
159
|
+
|
|
160
|
+
def git(*args, allow_failure: false)
|
|
161
|
+
out, err, status = Open3.capture3("git", "-C", @config.root, *args)
|
|
162
|
+
return out.chomp if status.success?
|
|
163
|
+
raise Error, "git #{args.join(" ")} failed: #{err.chomp}" unless allow_failure
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/duel/cli.rb
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "optparse"
|
|
7
|
+
require_relative "runner"
|
|
8
|
+
require_relative "baseline"
|
|
9
|
+
require_relative "lock"
|
|
10
|
+
require_relative "summary"
|
|
11
|
+
require_relative "json_report"
|
|
12
|
+
require_relative "init"
|
|
13
|
+
|
|
14
|
+
module Duel
|
|
15
|
+
# The `duel` command (and `duel init`: see Init). What a run behind a
|
|
16
|
+
# rake task or a CI expression can't pass as an option can be set in
|
|
17
|
+
# the environment (DUEL_BASE, DUEL_FILTER, DUEL_FORCE), and options win.
|
|
18
|
+
# On GitHub Actions (GITHUB_STEP_SUMMARY), it adds its report to the
|
|
19
|
+
# job's summary, in Markdown (see Summary). --markdown writes that to a
|
|
20
|
+
# file, and --json the report as JSON (see JSONReport), whatever the
|
|
21
|
+
# run's outcome, but not for a command line that isn't valid.
|
|
22
|
+
# @api private
|
|
23
|
+
class CLI
|
|
24
|
+
USAGE = "Usage: duel [options]\n duel init [options]\n\n" \
|
|
25
|
+
"Fails if this tree is slower than a baseline at any benchmark. `duel init` sets Duel up in a project.\n\n"
|
|
26
|
+
|
|
27
|
+
# Where GitHub Actions has a step add to its job's summary.
|
|
28
|
+
STEP_SUMMARY = "GITHUB_STEP_SUMMARY"
|
|
29
|
+
|
|
30
|
+
# The variables that set options, to their values, and the one that
|
|
31
|
+
# sets a flag by being set at all.
|
|
32
|
+
ENV_OPTIONS = {"DUEL_BASE" => "--base", "DUEL_FILTER" => "--filter"}.freeze
|
|
33
|
+
ENV_FLAGS = {"DUEL_FORCE" => "--force"}.freeze
|
|
34
|
+
|
|
35
|
+
def initialize(argv, env: ENV, out: $stdout, err: $stderr)
|
|
36
|
+
@argv = argv
|
|
37
|
+
@env = env
|
|
38
|
+
@out = out
|
|
39
|
+
@err = err
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Runs, and returns the exit status: 0 not slower (or nothing to compare),
|
|
43
|
+
# 1 slower (or a benchmark failed), 2 couldn't compare.
|
|
44
|
+
def run
|
|
45
|
+
return Init.new(@argv.drop(1), out: @out, err: @err).run if @argv.first == "init"
|
|
46
|
+
|
|
47
|
+
settings = @settings = catch(:version) { parse } or return 0
|
|
48
|
+
config = settings.config_path ? Duel.load_config(settings.config_path) : Config.new
|
|
49
|
+
Lock.hold(File.join(config.root, "tmp", "duel")) { compare(config, settings) }
|
|
50
|
+
rescue Error => e
|
|
51
|
+
@err.puts "duel: #{e}"
|
|
52
|
+
unfinished("couldn't compare", "error", "duel: #{e}", 2)
|
|
53
|
+
rescue OptionParser::ParseError => e
|
|
54
|
+
@err.puts "duel: #{e}"
|
|
55
|
+
2
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# +source+ as a Regexp: one that isn't is an option's invalid
|
|
59
|
+
# argument (a usage error, as any is).
|
|
60
|
+
def self.regexp(source)
|
|
61
|
+
Regexp.new(source)
|
|
62
|
+
rescue RegexpError => e
|
|
63
|
+
raise OptionParser::InvalidArgument, "#{source} (#{e})"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The settings, from the environment and then the options.
|
|
67
|
+
def parse
|
|
68
|
+
settings = Settings.defaults
|
|
69
|
+
settings.jobs = [Etc.nprocessors / 2, 1].max
|
|
70
|
+
parser(settings).parse!(env_options + @argv)
|
|
71
|
+
settings.config_path ||= (".duel.rb" if File.exist?(".duel.rb"))
|
|
72
|
+
settings.config_path &&= File.expand_path(settings.config_path)
|
|
73
|
+
settings
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# What #run does, holding the project's lock (see Lock).
|
|
79
|
+
def compare(config, settings)
|
|
80
|
+
baseline = Baseline.new(config, settings)
|
|
81
|
+
if @clean
|
|
82
|
+
baseline.clean
|
|
83
|
+
@out.puts "duel: removed tmp/duel and its baseline's worktree"
|
|
84
|
+
return 0
|
|
85
|
+
end
|
|
86
|
+
return nothing("no release tag (#{config.release_tags}) before HEAD to compare against") if settings.base_dir.nil? && baseline.ref.nil?
|
|
87
|
+
if !settings.force && baseline.unchanged?
|
|
88
|
+
return nothing("#{config.watch.join(", ")} unchanged since the baseline, so there's nothing to compare (--force to anyway)")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
candidate = settings.candidate_dir || config.root
|
|
92
|
+
baseline.build(candidate, "the candidate")
|
|
93
|
+
base_root = baseline.root
|
|
94
|
+
uneven(base_root, candidate)
|
|
95
|
+
runner = Runner.new(config, settings, base_root, out: @out)
|
|
96
|
+
results = runner.run
|
|
97
|
+
status = runner.passed?(results) ? 0 : 1
|
|
98
|
+
publish(
|
|
99
|
+
-> {
|
|
100
|
+
Summary.new(runner, results, settings: settings, baseline: described_baseline(config, settings, baseline),
|
|
101
|
+
candidate: described_candidate(config, settings)).markdown
|
|
102
|
+
},
|
|
103
|
+
-> {
|
|
104
|
+
JSONReport.new(runner, results, settings: settings, exit: status, baseline: baseline_json(config, settings, baseline, base_root),
|
|
105
|
+
candidate: candidate_json(config, settings)).to_h
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
status
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Says there's nothing to compare, and why, and passes.
|
|
112
|
+
def nothing(why)
|
|
113
|
+
@out.puts "duel: #{why}"
|
|
114
|
+
unfinished("nothing to compare", "nothing_to_compare", "duel: #{why}", 0)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Reports a run that didn't compare (+title+ says why in Markdown,
|
|
118
|
+
# and +status+ in JSON) with +message+, and returns +exit+.
|
|
119
|
+
def unfinished(title, status, message, exit)
|
|
120
|
+
# (Parsed, so set, where there's JSON to write: see #publish.)
|
|
121
|
+
settings = @settings #: Settings
|
|
122
|
+
publish(-> { Summary.note(title, message) }, -> { JSONReport.note(status, exit, message, settings) })
|
|
123
|
+
exit
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Writes the reports where they're asked for: the JSON +json+ makes
|
|
127
|
+
# to the file --json names, and the Markdown +markdown+ makes to the
|
|
128
|
+
# file --markdown names, and added to the job's summary on GitHub
|
|
129
|
+
# Actions (STEP_SUMMARY). Each is only made if it's written.
|
|
130
|
+
def publish(markdown, json)
|
|
131
|
+
path = @json
|
|
132
|
+
write(path, "#{JSON.pretty_generate(json.call)}\n") if path
|
|
133
|
+
path = @markdown
|
|
134
|
+
summary = (variables.fetch(STEP_SUMMARY) if variables.key?(STEP_SUMMARY))
|
|
135
|
+
return unless path || summary
|
|
136
|
+
|
|
137
|
+
text = markdown.call
|
|
138
|
+
write(path, text) if path
|
|
139
|
+
File.write(summary, text, mode: "a") if summary
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Writes +text+ to +path+, in a directory made for it.
|
|
143
|
+
def write(path, text)
|
|
144
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
145
|
+
File.write(path, text)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The baseline, in Markdown: its directory, or its commit, and what
|
|
149
|
+
# named it.
|
|
150
|
+
def described_baseline(config, settings, baseline)
|
|
151
|
+
dir = settings.base_dir
|
|
152
|
+
return Summary.code(dir) if dir
|
|
153
|
+
|
|
154
|
+
# (There's one: #compare checked.)
|
|
155
|
+
ref = baseline.ref #: String
|
|
156
|
+
commit = Summary.code(git(config.root, "rev-parse", "--short", "#{ref}^{commit}").first.chomp)
|
|
157
|
+
case settings.base
|
|
158
|
+
when nil then "#{commit}, where HEAD left #{Summary.code(baseline.main_branch)}"
|
|
159
|
+
when Baseline::LAST_RELEASE then "#{Summary.code(ref)} (#{commit}), the last release"
|
|
160
|
+
else ref.match?(/\A\h+\z/) ? commit : "#{Summary.code(ref)} (#{commit})"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The candidate, in Markdown: its directory, or its commit, and
|
|
165
|
+
# whether what Config#watch names has changes not committed.
|
|
166
|
+
def described_candidate(config, settings)
|
|
167
|
+
dir = settings.candidate_dir || config.root
|
|
168
|
+
head, found = git(dir, "rev-parse", "--short", "HEAD")
|
|
169
|
+
return Summary.code(dir) unless found.success?
|
|
170
|
+
|
|
171
|
+
_, clean = git(dir, "diff", "--quiet", "HEAD", "--", *config.watch)
|
|
172
|
+
"#{Summary.code(head.chomp)}#{", with changes not committed" unless clean.success?}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The baseline, for JSON: the ref that named it (the main branch,
|
|
176
|
+
# where HEAD left it), its commit, and its tree (+root+), or only its
|
|
177
|
+
# tree, a directory.
|
|
178
|
+
def baseline_json(config, settings, baseline, root)
|
|
179
|
+
described = {"ref" => nil, "commit" => nil, "root" => File.expand_path(root)}
|
|
180
|
+
return described if settings.base_dir
|
|
181
|
+
|
|
182
|
+
# (There's one: #compare checked.)
|
|
183
|
+
ref = baseline.ref #: String
|
|
184
|
+
named = case settings.base
|
|
185
|
+
when nil then baseline.main_branch
|
|
186
|
+
when Baseline::LAST_RELEASE then ref
|
|
187
|
+
else settings.base
|
|
188
|
+
end
|
|
189
|
+
described.merge("ref" => named, "commit" => git(config.root, "rev-parse", "#{ref}^{commit}").first.chomp)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The candidate, for JSON: its commit, and whether what Config#watch
|
|
193
|
+
# names has changes not committed (each nil outside a repository), and
|
|
194
|
+
# its tree.
|
|
195
|
+
def candidate_json(config, settings)
|
|
196
|
+
dir = File.expand_path(settings.candidate_dir || config.root)
|
|
197
|
+
head, found = git(dir, "rev-parse", "HEAD")
|
|
198
|
+
return {"commit" => nil, "uncommitted_changes" => nil, "root" => dir} unless found.success?
|
|
199
|
+
|
|
200
|
+
_, clean = git(dir, "diff", "--quiet", "HEAD", "--", *config.watch)
|
|
201
|
+
{"commit" => head.chomp, "uncommitted_changes" => !clean.success?, "root" => dir}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Git's output, and its status, in +dir+.
|
|
205
|
+
def git(dir, *args)
|
|
206
|
+
out, _, status = Open3.capture3("git", "-C", dir, *args)
|
|
207
|
+
[out, status]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Says so if the two versions' paths differ in length (resolved): what
|
|
211
|
+
# a version does with its own paths takes longer on a longer path, so
|
|
212
|
+
# one may measure a little slower than the other.
|
|
213
|
+
def uneven(base_root, candidate)
|
|
214
|
+
longer = File.realpath(base_root).length - File.realpath(candidate).length
|
|
215
|
+
return if longer.zero?
|
|
216
|
+
|
|
217
|
+
@out.puts "duel: the baseline's path is #{Runner.count(longer.abs, "character")} #{longer.positive? ? "longer" : "shorter"} than the " \
|
|
218
|
+
"candidate's, so it may measure a little #{longer.positive? ? "slower" : "faster"}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The DUEL_* variables set, as options (empty is as good as unset,
|
|
222
|
+
# which CI expressions find handy).
|
|
223
|
+
def env_options
|
|
224
|
+
set = variables
|
|
225
|
+
ENV_OPTIONS.flat_map { |name, option| set.key?(name) ? [option, set.fetch(name)] : [] } +
|
|
226
|
+
ENV_FLAGS.filter_map { |name, flag| flag if set.key?(name) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The environment's variables that are set (not empty).
|
|
230
|
+
def variables = @env.reject { |_, value| value.empty? }
|
|
231
|
+
|
|
232
|
+
def parser(settings)
|
|
233
|
+
OptionParser.new do |o|
|
|
234
|
+
o.banner = USAGE
|
|
235
|
+
o.on("--base REF|DIR", "Baseline: a commit (default: where HEAD left the main branch), last-release (the newest",
|
|
236
|
+
"release tag), or a directory, already built") do |v|
|
|
237
|
+
# (A directory is one, whatever else it could name, and the last given wins.)
|
|
238
|
+
directory = File.directory?(v)
|
|
239
|
+
settings.base = (v unless directory)
|
|
240
|
+
settings.base_dir = (v if directory)
|
|
241
|
+
end
|
|
242
|
+
o.on("--candidate-dir DIR", "Candidate tree (default: this one)") { |v| settings.candidate_dir = v }
|
|
243
|
+
o.on("--filter REGEXP", "Only benchmarks whose names match") { |v| settings.filter = CLI.regexp(v) }
|
|
244
|
+
o.on("--jobs N", Integer, "Benchmarks measured at once (default: half the CPUs)") { |v| settings.jobs = v }
|
|
245
|
+
o.on("--processes N", Integer, "Worker processes per version (default: 3)") { |v| settings.processes = v }
|
|
246
|
+
o.on("--timeout SECONDS", Float, "Most a worker may take to load or answer (default: 60)") { |v| settings.timeout = v }
|
|
247
|
+
o.on("--limit RATIO", Float, "Ratio above which a timing is slower (default: 1.02)") { |v| settings.limit = v }
|
|
248
|
+
o.on("--warm SECONDS", Float, "Warm-up per benchmark (default: 5 on JRuby and TruffleRuby, 2 with YJIT on, else 0)") { |v| settings.warm = v }
|
|
249
|
+
o.on("--ruby version-file|PATH", "Each version's Ruby: the one its version file names (.ruby-version, say), or a",
|
|
250
|
+
"path (default: c.ruby, or this one)") { |v| settings.ruby = (v == "version-file") ? :version_file : v }
|
|
251
|
+
o.on("--force", "Compare even if nothing watched changed") { settings.force = true }
|
|
252
|
+
o.on("--config PATH", "Config file (default: .duel.rb)") { |v| settings.config_path = v }
|
|
253
|
+
o.on("--markdown PATH", "Also write the report to PATH in Markdown, for a pull request comment (on GitHub",
|
|
254
|
+
"Actions, it's added to the job's summary too)") { |v| @markdown = v }
|
|
255
|
+
o.on("--json PATH", "Also write the report to PATH as JSON, for a program to read") { |v| @json = v }
|
|
256
|
+
o.on("--clean", "Remove tmp/duel, and the baseline's git worktree, and stop") { @clean = true }
|
|
257
|
+
o.on("-v", "--version", "Print the version") do
|
|
258
|
+
@out.puts "duel #{VERSION}"
|
|
259
|
+
throw :version
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|