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

Classes: Selection, Stats

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

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.

Returns:

  • ([Array<String>, Stats])


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.

Returns:

  • ([String, Symbol], nil)

    the base and how it was arrived at (:merge_base, or :head_fallback when no default-branch ref exists), or nil when there is no HEAD at all (a repository with no commits).



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.

Raises:



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.

Returns:

  • ([String, Boolean])

    stdout and whether git exited 0



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

Returns:

  • (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.expand_path(path)
end

.select(changed: false, base: nil, root: Dir.pwd) ⇒ Selection

Parameters:

  • changed (Boolean) (defaults to: false)

    restrict to files in the diff

  • base (String, nil) (defaults to: nil)

    explicit diff base; nil means "work it out"

  • root (String) (defaults to: Dir.pwd)

    directory to select within

Returns:

Raises:

  • (UsageError)

    when --changed is used outside a git repository. Typed rather than a crash or a silent empty set: the exit-code contract makes this a misuse (2), and the CLI maps it later.



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.

Returns:

  • (String, nil)

    absolute relative to prefix, or nil if outside



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