constable-rails 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/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +515 -0
- data/exe/constable +7 -0
- data/lib/constable/case.rb +336 -0
- data/lib/constable/cli.rb +475 -0
- data/lib/constable/cold_case/minitest.rb +342 -0
- data/lib/constable/cold_case/rspec.rb +334 -0
- data/lib/constable/cold_case.rb +280 -0
- data/lib/constable/config.rb +125 -0
- data/lib/constable/coverage.rb +951 -0
- data/lib/constable/diff.rb +212 -0
- data/lib/constable/dsl.rb +833 -0
- data/lib/constable/identity.rb +121 -0
- data/lib/constable/importer/modernizer.rb +860 -0
- data/lib/constable/importer/reopener.rb +468 -0
- data/lib/constable/importer.rb +51 -0
- data/lib/constable/investigation.rb +67 -0
- data/lib/constable/isolation.rb +171 -0
- data/lib/constable/jail.rb +399 -0
- data/lib/constable/log_router.rb +197 -0
- data/lib/constable/matchers.rb +834 -0
- data/lib/constable/order_audit.rb +130 -0
- data/lib/constable/rails_support.rb +213 -0
- data/lib/constable/railtie.rb +36 -0
- data/lib/constable/registry.rb +57 -0
- data/lib/constable/reporter.rb +625 -0
- data/lib/constable/result.rb +149 -0
- data/lib/constable/runner.rb +697 -0
- data/lib/constable/selection.rb +205 -0
- data/lib/constable/storage/adapter.rb +91 -0
- data/lib/constable/storage/mysql_adapter.rb +125 -0
- data/lib/constable/storage/postgres_adapter.rb +125 -0
- data/lib/constable/storage/sqlite_adapter.rb +84 -0
- data/lib/constable/storage.rb +847 -0
- data/lib/constable/version.rb +5 -0
- data/lib/constable/warrants.rb +290 -0
- data/lib/constable-rails.rb +16 -0
- data/lib/constable.rb +151 -0
- data/lib/generators/constable/base.rb +99 -0
- data/lib/generators/constable/channel/channel_generator.rb +20 -0
- data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
- data/lib/generators/constable/controller/controller_generator.rb +25 -0
- data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
- data/lib/generators/constable/generator/generator_generator.rb +31 -0
- data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
- data/lib/generators/constable/helper/helper_generator.rb +23 -0
- data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
- data/lib/generators/constable/import_generator.rb +137 -0
- data/lib/generators/constable/install_generator.rb +188 -0
- data/lib/generators/constable/integration/integration_generator.rb +27 -0
- data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
- data/lib/generators/constable/job/job_generator.rb +20 -0
- data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
- data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
- data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
- data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
- data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
- data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
- data/lib/generators/constable/model/model_generator.rb +31 -0
- data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
- data/lib/generators/constable/resource/resource_generator.rb +27 -0
- data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
- data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
- data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
- data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
- data/lib/generators/constable/system/system_generator.rb +20 -0
- data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
- data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
- data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
- data/lib/generators/constable/templates/config.yml.tt +67 -0
- data/lib/generators/constable/templates/example_case.rb.tt +56 -0
- data/lib/generators/constable/templates/matchers.rb.tt +36 -0
- data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
- metadata +209 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Constable
|
|
7
|
+
# The git helper -- "what changed on this beat".
|
|
8
|
+
#
|
|
9
|
+
# Two consumers, one implementation:
|
|
10
|
+
#
|
|
11
|
+
# * the Runner's git-diff test selection ("run the cases for the files I touched"),
|
|
12
|
+
# * the diff-based coverage gate ("hold the lines I touched to the threshold").
|
|
13
|
+
#
|
|
14
|
+
# Both ask the same question -- what is different between this working tree and the
|
|
15
|
+
# point where this branch left the mainline -- so both ask it here, and neither has to
|
|
16
|
+
# know how git spells the answer.
|
|
17
|
+
#
|
|
18
|
+
# Everything degrades to "no diff info" rather than raising: git may not be installed,
|
|
19
|
+
# the directory may not be a repository, the repository may have no commits yet, or the
|
|
20
|
+
# whole thing may be running from a tarball in CI. In every one of those cases
|
|
21
|
+
# `available?` is false, `changed_files` is `[]` and `changed_lines` is `{}` -- callers
|
|
22
|
+
# fall back to running everything, which is always the safe direction.
|
|
23
|
+
module Diff
|
|
24
|
+
# Candidate mainlines, tried in order. `origin/*` first: on a CI checkout the local
|
|
25
|
+
# `main` may not exist at all, and where it does it can be stale.
|
|
26
|
+
DEFAULT_BASES = %w[origin/main origin/master origin/HEAD main master].freeze
|
|
27
|
+
|
|
28
|
+
# git's well-known empty tree. Diffing against it makes a repository's very first
|
|
29
|
+
# commit read as "every line is new", which is exactly right -- on a one-commit
|
|
30
|
+
# repository there is no earlier state for any of it to be older than.
|
|
31
|
+
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
32
|
+
|
|
33
|
+
HUNK_HEADER = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
|
34
|
+
NEW_FILE_HEADER = %r{\A\+\+\+ (?:b/)?(.+)\z}
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# True when git can actually answer questions here: the binary exists, this is a
|
|
39
|
+
# repository, and it has at least one commit to compare against.
|
|
40
|
+
def available?(root: Constable.root)
|
|
41
|
+
!head_sha(root: root).nil?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The absolute path of the repository's top level, or nil when there isn't one.
|
|
45
|
+
# Paths from `changed_files`/`changed_lines` are relative to *this*, not to
|
|
46
|
+
# `Constable.root` -- the two are usually the same directory, but an engine or a
|
|
47
|
+
# monorepo package can sit below the repository root.
|
|
48
|
+
def git_root(root: Constable.root)
|
|
49
|
+
out = git("rev-parse", "--show-toplevel", root: root)
|
|
50
|
+
out && !out.strip.empty? ? File.realpath(out.strip) : nil
|
|
51
|
+
rescue Errno::ENOENT
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Every file that differs from the merge base and still exists on disk, plus every
|
|
56
|
+
# untracked file. Deletions are dropped: there is nothing left to test or cover.
|
|
57
|
+
#
|
|
58
|
+
# Diff.changed_files #=> ["app/models/user.rb", "test/cases/user_case.rb"]
|
|
59
|
+
# Diff.changed_files(absolute: true) #=> ["/repo/app/models/user.rb", ...]
|
|
60
|
+
# Diff.changed_files(since: "main") # explicit base instead of the detected one
|
|
61
|
+
#
|
|
62
|
+
# Returns [] when git can't answer.
|
|
63
|
+
def changed_files(since: nil, root: Constable.root, absolute: false)
|
|
64
|
+
base = resolve_base(since: since, root: root)
|
|
65
|
+
return [] unless base
|
|
66
|
+
|
|
67
|
+
tracked = git("diff", "--name-only", "--diff-filter=d", base, "--", root: root).to_s.lines
|
|
68
|
+
files = (tracked.map(&:chomp) + untracked_files(root: root)).map { |p| unquote(p) }
|
|
69
|
+
files = files.uniq.reject(&:empty?)
|
|
70
|
+
absolute ? files.map { |p| File.expand_path(p, git_root(root: root) || root) } : files
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The changed *line numbers* per file, as a Set of integers keyed by path:
|
|
74
|
+
#
|
|
75
|
+
# Diff.changed_lines #=> { "app/models/user.rb" => #<Set: {12, 13, 40}> }
|
|
76
|
+
# Diff.changed_lines(absolute: true) #=> { "/repo/app/models/user.rb" => ... }
|
|
77
|
+
#
|
|
78
|
+
# Only lines that exist in the *new* state are reported -- an added or modified line
|
|
79
|
+
# has a number in the file you can go and look at; a deleted line does not. Untracked
|
|
80
|
+
# files count as changed in their entirety, since none of their lines existed before.
|
|
81
|
+
#
|
|
82
|
+
# Returns {} when git can't answer.
|
|
83
|
+
def changed_lines(since: nil, root: Constable.root, absolute: false)
|
|
84
|
+
base = resolve_base(since: since, root: root)
|
|
85
|
+
return {} unless base
|
|
86
|
+
|
|
87
|
+
out = git("diff", "--unified=0", "--no-color", "--no-ext-diff", "--diff-filter=d", base, "--", root: root)
|
|
88
|
+
lines = parse_unified_diff(out.to_s)
|
|
89
|
+
base_dir = git_root(root: root) || root.to_s
|
|
90
|
+
|
|
91
|
+
untracked_files(root: root).each do |path|
|
|
92
|
+
full = File.expand_path(path, base_dir)
|
|
93
|
+
next unless File.file?(full)
|
|
94
|
+
|
|
95
|
+
count = File.readlines(full).size
|
|
96
|
+
(lines[path] ||= Set.new).merge(1..count) if count.positive?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return lines unless absolute
|
|
100
|
+
|
|
101
|
+
lines.each_with_object({}) { |(path, nums), out_hash| out_hash[File.expand_path(path, base_dir)] = nums }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The revision everything is compared against, or nil when there isn't one.
|
|
105
|
+
#
|
|
106
|
+
# Order of preference:
|
|
107
|
+
# 1. an explicit `since:` (a branch, tag or sha -- whatever the caller passed),
|
|
108
|
+
# 2. the merge base with the first of DEFAULT_BASES that exists *and* isn't HEAD
|
|
109
|
+
# itself -- the branch-point, which is what "what did I change" means on a
|
|
110
|
+
# feature branch,
|
|
111
|
+
# 3. HEAD, when we're sitting on the mainline with uncommitted work -- the diff is
|
|
112
|
+
# then simply the working tree,
|
|
113
|
+
# 4. HEAD~1, when the tree is clean -- "what did the last commit change",
|
|
114
|
+
# 5. the empty tree, on a repository with a single commit and nothing else to
|
|
115
|
+
# compare to.
|
|
116
|
+
def resolve_base(since: nil, root: Constable.root)
|
|
117
|
+
head = head_sha(root: root)
|
|
118
|
+
return nil unless head
|
|
119
|
+
|
|
120
|
+
if since
|
|
121
|
+
resolved = rev_parse(since, root: root)
|
|
122
|
+
return resolved
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
DEFAULT_BASES.each do |ref|
|
|
126
|
+
next unless rev_parse(ref, root: root)
|
|
127
|
+
|
|
128
|
+
merge_base = git("merge-base", "HEAD", ref, root: root)&.strip
|
|
129
|
+
next if merge_base.nil? || merge_base.empty? || merge_base == head
|
|
130
|
+
|
|
131
|
+
return merge_base
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
return head if dirty?(root: root)
|
|
135
|
+
|
|
136
|
+
rev_parse("HEAD~1", root: root) || EMPTY_TREE
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# True when there is uncommitted work (staged, unstaged or untracked).
|
|
140
|
+
def dirty?(root: Constable.root)
|
|
141
|
+
out = git("status", "--porcelain", root: root)
|
|
142
|
+
!out.nil? && !out.strip.empty?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def head_sha(root: Constable.root)
|
|
146
|
+
rev_parse("HEAD", root: root)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# --- internals -------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def untracked_files(root: Constable.root)
|
|
152
|
+
git("ls-files", "--others", "--exclude-standard", root: root).to_s.lines.map { |l| unquote(l.chomp) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def rev_parse(ref, root: Constable.root)
|
|
156
|
+
out = git("rev-parse", "--verify", "--quiet", "#{ref}^{commit}", root: root)
|
|
157
|
+
out.nil? || out.strip.empty? ? nil : out.strip
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Walks `git diff --unified=0` output. The `+++ b/path` line names the file in its new
|
|
161
|
+
# state (so renames land under the new name, which is the one on disk); each `@@`
|
|
162
|
+
# header carries the new-side start line and count.
|
|
163
|
+
def parse_unified_diff(output)
|
|
164
|
+
current = nil
|
|
165
|
+
output.each_line.with_object({}) do |line, files|
|
|
166
|
+
line = line.chomp
|
|
167
|
+
|
|
168
|
+
if (match = NEW_FILE_HEADER.match(line))
|
|
169
|
+
path = unquote(match[1])
|
|
170
|
+
# git spells a missing side of the diff "/dev/null" in its own output -- that
|
|
171
|
+
# is a marker in a text stream, not this platform's null device.
|
|
172
|
+
current = path == "/dev/null" ? nil : path # rubocop:disable Style/FileNull
|
|
173
|
+
elsif current && (match = HUNK_HEADER.match(line))
|
|
174
|
+
start = match[1].to_i
|
|
175
|
+
count = match[2] ? match[2].to_i : 1
|
|
176
|
+
(files[current] ||= Set.new).merge(start...(start + count)) if count.positive?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# git quotes paths containing anything exotic, C-string style. Unwrap the common case
|
|
182
|
+
# rather than pretending such paths don't exist.
|
|
183
|
+
def unquote(path)
|
|
184
|
+
return path unless path.start_with?('"') && path.end_with?('"') && path.length > 1
|
|
185
|
+
|
|
186
|
+
body = path[1..-2]
|
|
187
|
+
body.gsub(/\\([0-7]{3}|.)/) do
|
|
188
|
+
token = Regexp.last_match(1)
|
|
189
|
+
case token
|
|
190
|
+
when "n" then "\n"
|
|
191
|
+
when "t" then "\t"
|
|
192
|
+
when '"', "\\" then token
|
|
193
|
+
else token.match?(/\A[0-7]{3}\z/) ? token.to_i(8).chr : token
|
|
194
|
+
end
|
|
195
|
+
end.force_encoding(Encoding::UTF_8)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Every shell-out funnels through here: argument array (never a shell string), a
|
|
199
|
+
# fixed working directory, and nil rather than an exception on any kind of failure.
|
|
200
|
+
def git(*args, root: Constable.root)
|
|
201
|
+
dir = root.to_s
|
|
202
|
+
return nil unless File.directory?(dir)
|
|
203
|
+
|
|
204
|
+
out, _err, status = Open3.capture3("git", *args, chdir: dir)
|
|
205
|
+
return nil unless status.success?
|
|
206
|
+
|
|
207
|
+
out.dup.force_encoding(Encoding::UTF_8).scrub
|
|
208
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, IOError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|