Module: SpecGuard::RSpec::FileSelector
- Defined in:
- lib/specguard/rspec/file_selector.rb
Overview
Chooses which spec files the linter reads.
Two modes: every *_spec.rb under the working directory (the default), or
only those in the current diff (--changed).
Why --changed is not git diff --name-only
The Client Gem spec words --changed as "files in the current diff (via
git diff --name-only)". Taken literally that is broken in CI: bare
git diff --name-only compares the working tree against the index, and
CI checks out a commit and leaves the tree clean. It therefore matches
nothing, the linter selects zero files, finds zero annotations, and exits
0 — the CI gate the tool exists to provide, silently a no-op.
So the diff base is an explicit decision, made here and documented here rather than inherited by accident:
* `--changed` diffs against the **merge base with the default branch**
(`origin/HEAD`, falling back to `origin/main`/`origin/master` and then
their local counterparts). On a feature branch that is exactly "what
this branch changed", whether or not the change is committed yet —
`git diff <base>` with no second commit compares the base against the
*working tree*, so it covers both.
* `--changed=<base>` overrides it, for a CI system that knows better
(a PR's target ref, say).
This still legitimately selects zero files, and that is not a bug to
be fixed by a cleverer base: on a default-branch build after a merge,
HEAD == origin/main, so the merge base is HEAD and nothing differs.
There is no diff base that makes "what changed on this build" non-empty
there.
That is why the load-bearing requirement is the loud empty selection, not the base. The caller must never be unable to tell "checked 12 files, found no annotations" from "checked 0 files" — Selection carries the count, the emptiness, and Stats explaining which filter emptied it, so the CLI can say so on stderr, accurately. A confidently wrong reason is worse than a quiet one: a human who reads "nothing in the diff matched *_spec.rb" stops looking. The exit code is not the lever: the spec fixes 0 for "no annotations".
Scope: --changed selects changed specs under root
The second explicit decision. git diff --name-only emits paths relative
to the repository root, not to the process's working directory, so the
two are only the same when you happen to stand at the top level. Selecting
by raw git output would make --changed repo-scoped while the default
mode is cwd-scoped (Dir.glob under root) — the same invocation would
mean different things in the two modes.
--changed is therefore cwd-scoped, to match the default mode: git's
repo-relative paths are resolved against git rev-parse --show-toplevel,
anything outside root is dropped, and what survives is returned relative
to root — exactly the shape FileSelector.select_all returns. Running from
<repo>/sub selects the changed specs under sub, and counts the ones
it dropped (stats.outside_root) so an empty selection can say "3
changed spec files, all outside this directory" instead of the falsehood
"nothing in the diff matched".
Paths come back from git diff -z: NUL-separated, and therefore never
quoted. Without -z, core.quotePath (on by default) renders
spec/café_spec.rb as the literal characters "spec/caf\303\251_spec.rb",
quotes and all, which no longer names a file — an accented spec file would
be silently dropped and then misreported as "nothing changed".
Known limitation: git diff cannot see untracked files, so a brand
new spec file that has not been git added is not selected. In CI (a
clean checkout of a commit) that cannot arise; locally the loud empty
selection is what surfaces it.
Defined Under Namespace
Constant Summary collapse
- DEFAULT_GLOB =
"**/*_spec.rb"- DEFAULT_BRANCH_REFS =
Ordered probes for the default branch when no explicit base is given.
%w[origin/HEAD origin/main origin/master main master].freeze
Class Method Summary collapse
-
.base_note(resolved, base_kind, root) ⇒ Object
Explains a base that can only produce a thin selection, and — the point of
base_kind— distinguishes the two ways that happens. -
.changed_files(base, root) ⇒ [Array<String>, Stats]
Resolves git's repo-root-relative output into paths relative to
root, dropping (and counting) everything the two scoping rules exclude. -
.default_base(root) ⇒ [String, Symbol]?
The merge base of HEAD with the default branch.
-
.diff_names(base, root) ⇒ Object
--diff-filter=ddrops deleted paths —git diff --name-onlylists them, and they then fail to open. - .directory_prefix(path) ⇒ Object
-
.git(args, root) ⇒ [String, Boolean]
Runs git without a shell and without inheriting stderr into our output.
- .git_repository?(root) ⇒ Boolean
-
.real_path(path) ⇒ Object
Symlink-resolved, so a
rootreached through a symlink still compares equal to the physical path git reports. - .select(changed: false, base: nil, root: Dir.pwd) ⇒ Selection
-
.select_all(root: Dir.pwd) ⇒ Object
Every
*_spec.rbunderroot, recursively. - .select_changed(base: nil, root: Dir.pwd) ⇒ Object
-
.strip_prefix(absolute, prefix) ⇒ String?
Byte-wise, so a path git returned that is not valid in the prefix's encoding cannot raise Encoding::CompatibilityError mid-selection.
-
.toplevel(root) ⇒ Object
The repository's top level — what
git diff's paths are relative to.
Class Method Details
.base_note(resolved, base_kind, root) ⇒ Object
Explains a base that can only produce a thin selection, and — the point
of base_kind — distinguishes the two ways that happens. Both leave the
base at HEAD, but "this is a default-branch build" is normal and "no
default branch could be found" means --changed has quietly degraded to
git diff HEAD, i.e. the working-tree-vs-HEAD no-op this class exists
to avoid. Reporting the first when the second is true would be a
confidently wrong explanation.
222 223 224 225 226 227 228 229 230 231 232 233 234 |
# File 'lib/specguard/rspec/file_selector.rb', line 222 def base_note(resolved, base_kind, root) case base_kind when :head_fallback "no default-branch ref (#{DEFAULT_BRANCH_REFS.join(', ')}) could be found, so the diff base " \ "fell back to HEAD; --changed can only select uncommitted changes here" when :merge_base head, ok = git(%w[rev-parse HEAD], root) return nil unless ok && head.strip == resolved "the diff base is HEAD itself (this looks like a default-branch build), " \ "so only uncommitted changes can be selected" end end |
.changed_files(base, root) ⇒ [Array<String>, Stats]
Resolves git's repo-root-relative output into paths relative to root,
dropping (and counting) everything the two scoping rules exclude.
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 |
# File 'lib/specguard/rspec/file_selector.rb', line 147 def changed_files(base, root) names = diff_names(base, root) specs = names.select { |name| File.fnmatch?("*_spec.rb", name) } top = toplevel(root) prefix = directory_prefix(top.empty? ? root : top) root_prefix = directory_prefix(real_path(root)) files = [] outside = 0 unreadable = 0 specs.each do |name| absolute = prefix + name relative = strip_prefix(absolute, root_prefix) if relative.nil? outside += 1 elsif !File.file?(absolute) unreadable += 1 else files << relative end end [files.sort, Stats.new(changed: names.length, spec_matches: specs.length, outside_root: outside, unreadable: unreadable)] end |
.default_base(root) ⇒ [String, Symbol]?
The merge base of HEAD with the default branch.
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/specguard/rspec/file_selector.rb', line 198 def default_base(root) head, ok = git(%w[rev-parse --verify --quiet HEAD], root) return nil unless ok && !head.strip.empty? DEFAULT_BRANCH_REFS.each do |ref| resolved, found = git(%W[rev-parse --verify --quiet #{ref}], root) next unless found && !resolved.strip.empty? merge_base, ok = git(%W[merge-base HEAD #{ref}], root) return [merge_base.strip, :merge_base] if ok && !merge_base.strip.empty? end # Detached from any known default branch: fall back to HEAD, which # selects uncommitted work only. Better than selecting everything. [head.strip, :head_fallback] end |
.diff_names(base, root) ⇒ Object
--diff-filter=d drops deleted paths — git diff --name-only lists
them, and they then fail to open. -z makes the output machine-readable:
NUL-separated and never core.quotePath-quoted, so a non-ASCII path
survives intact and a path containing a newline cannot split a record.
179 180 181 182 183 184 |
# File 'lib/specguard/rspec/file_selector.rb', line 179 def diff_names(base, root) out, ok = git(%W[diff -z --name-only --diff-filter=d #{base} --], root) raise UsageError, "--changed could not diff against #{base.inspect}" unless ok out.split("\0").reject(&:empty?) end |
.directory_prefix(path) ⇒ Object
259 260 261 |
# File 'lib/specguard/rspec/file_selector.rb', line 259 def directory_prefix(path) path.end_with?(File::SEPARATOR) ? path : path + File::SEPARATOR end |
.git(args, root) ⇒ [String, Boolean]
Runs git without a shell and without inheriting stderr into our output.
243 244 245 246 247 248 249 |
# File 'lib/specguard/rspec/file_selector.rb', line 243 def git(args, root) out, _err, status = Open3.capture3("git", *args, chdir: root) [out, status.success?] rescue SystemCallError # git not installed / not executable. ["", false] end |
.git_repository?(root) ⇒ Boolean
236 237 238 239 |
# File 'lib/specguard/rspec/file_selector.rb', line 236 def git_repository?(root) out, ok = git(%w[rev-parse --is-inside-work-tree], root) ok && out.strip == "true" end |
.real_path(path) ⇒ Object
Symlink-resolved, so a root reached through a symlink still compares
equal to the physical path git reports.
253 254 255 256 257 |
# File 'lib/specguard/rspec/file_selector.rb', line 253 def real_path(path) File.realpath(path) rescue SystemCallError File.(path) end |
.select(changed: false, base: nil, root: Dir.pwd) ⇒ Selection
115 116 117 |
# File 'lib/specguard/rspec/file_selector.rb', line 115 def select(changed: false, base: nil, root: Dir.pwd) changed ? select_changed(base: base, root: root) : select_all(root: root) end |
.select_all(root: Dir.pwd) ⇒ Object
Every *_spec.rb under root, recursively. Hidden directories are not
traversed (no File::FNM_DOTMATCH), so .git and friends are skipped.
121 122 123 124 |
# File 'lib/specguard/rspec/file_selector.rb', line 121 def select_all(root: Dir.pwd) files = Dir.glob(DEFAULT_GLOB, base: root).select { |f| File.file?(File.join(root, f)) }.sort Selection.new(files: files, mode: :all) end |
.select_changed(base: nil, root: Dir.pwd) ⇒ Object
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/specguard/rspec/file_selector.rb', line 126 def select_changed(base: nil, root: Dir.pwd) unless git_repository?(root) raise UsageError, "--changed requires a git repository; #{root} is not inside one" end resolved, base_kind = base ? [base, :explicit] : default_base(root) unless resolved raise UsageError, "--changed could not determine a diff base (no #{DEFAULT_BRANCH_REFS.join(', ')} " \ "and no HEAD commit); pass --changed=<base> explicitly" end files, stats = changed_files(resolved, root) Selection.new(files: files, mode: :changed, base: resolved, note: base_note(resolved, base_kind, root), stats: stats) end |
.strip_prefix(absolute, prefix) ⇒ String?
Byte-wise, so a path git returned that is not valid in the prefix's encoding cannot raise Encoding::CompatibilityError mid-selection. The result is handed back in the path's own encoding.
267 268 269 270 271 272 273 |
# File 'lib/specguard/rspec/file_selector.rb', line 267 def strip_prefix(absolute, prefix) bytes = absolute.b head = prefix.b return nil unless bytes.start_with?(head) bytes[head.bytesize..].force_encoding(absolute.encoding) end |
.toplevel(root) ⇒ Object
The repository's top level — what git diff's paths are relative to.
Empty when git cannot say, in which case the caller falls back to root
(the top level is root for the common case of running from there).
189 190 191 192 |
# File 'lib/specguard/rspec/file_selector.rb', line 189 def toplevel(root) out, ok = git(%w[rev-parse --show-toplevel], root) ok ? real_path(out.strip) : "" end |