specguard-ruby 0.3.8 → 0.3.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df7e70603cb326ea7db7783c95263a418f42b2a2472c81af5013942e31e4a936
4
- data.tar.gz: e586dcea9b754ec7f953c4cbf9c24f661fc410916551cca16e5cfc39a80941b9
3
+ metadata.gz: c7d1dfb3eb7af542eee8c1202dae06dd0d9c3981f5415d12875037c9a9067944
4
+ data.tar.gz: 9d785e02b70ea9e06b0d0cf55ce31cbb1d67c841ea36fb0b7f3c7217a8f20304
5
5
  SHA512:
6
- metadata.gz: 7e5ca1fac05f5891a127a59bf052b2c7840738880a6d2f94c0aaa608cfb5f967af5aec59910c340af09c19fae22a791d12941071ba826d93a8cf04c171380e4a
7
- data.tar.gz: 5b91217eb10b3552f8c83a3890a3d2fc69e2740f0572f20f4980c9b372dd2d6e13a35575609b030947db53cd86f71a384b9d83c66adc6d04b311b5f422dbbe35
6
+ metadata.gz: 3d5ae9e309ae02ea33679223f712d04edf7c9e06aab22f011fde61484f2f3c8d2c992b02b792caceeb934b63136734840cdd01d0dbe62619b695dd09c3ceb238
7
+ data.tar.gz: 3d3f373483219a5cdbeecdf639065bd99cb90dd648acf4bd75174ae9eba6adefcfc94a6458577480bb526dc636d88b139a4742cda3c12269a320bd5e7f994a3f
data/README.md CHANGED
@@ -54,6 +54,31 @@ bundle exec specguard-lint --changed # CI mode: only files in the current diff
54
54
  bundle exec specguard-lint # one-off audit: every *_spec.rb
55
55
  ```
56
56
 
57
+ `--changed` is the CI selection mode: it diffs against the **merge base with
58
+ the default branch** (`origin/HEAD`, then `origin/main`/`origin/master`, then
59
+ their local names) — never a bare working-tree-vs-index `git diff`, which is
60
+ empty on a clean checkout. `--changed=<base>` overrides the base for pipelines
61
+ that know better. Selection also takes in **untracked** files: a brand-new
62
+ spec that has not been `git add`ed is part of what the branch changed, whether
63
+ or not the change is committed yet. One `git ls-files --others
64
+ --exclude-standard` call per run is unioned with the diff — `.gitignore`d
65
+ paths (scratch directories, vendored code, build output) never enter the
66
+ selection — and untracked files obey the same scoping as diffed ones, so an
67
+ untracked spec outside the current directory is counted as outside, not
68
+ checked. When the untracked leg contributed, the `checked N spec files changed
69
+ since <base>` line says `including M untracked`.
70
+ In a **shallow** checkout — the default depth-1 `git clone`
71
+ behind `actions/checkout@v4` — the merge base with the default branch is not
72
+ in the clone's history, so the derived base is HEAD itself and the selection
73
+ comes up empty: it still exits `0`, but its stderr note (and `--json`
74
+ `selection.note`) names the checkout as **shallow** and the remedy — fetch the
75
+ default branch (`fetch-depth: 0`) or pass `--changed=<base>` naming a base the
76
+ checkout contains. An explicit `--changed=<base>` that is not in a shallow
77
+ checkout's history is a usage error (exit `2`) naming that cause and the same
78
+ remedy, not the bare "could not diff against" a full clone reports for a
79
+ genuinely bad ref. A selection that comes up empty stays exit `0` and says WHY
80
+ on stderr — the exit contract is unchanged; only the WHY is truthful now.
81
+
57
82
  Files are **positional** (`specguard-lint spec/order_spec.rb`); there is no `--source` flag —
58
83
  that belongs to `validate-intent`, not to this one.
59
84
 
@@ -228,14 +228,23 @@ module SpecGuard
228
228
  # `summary.files`. The warnings are diagnostics about the linter rather
229
229
  # than findings, so they stay on stderr exactly as they are — a run that
230
230
  # selected nothing is still loud, in both renderers.
231
+ #
232
+ # The `--changed` count names its provenance: files can reach the
233
+ # selection through the untracked leg (`file_selector.rb`), and "changed
234
+ # since <base>" alone over-claims for a file the diff never saw. When
235
+ # the untracked leg contributed, the line says how many — the same
236
+ # `stats.untracked` the selection carries, so the clause can only ever
237
+ # name files this run actually checked.
231
238
  def report_selection(selection, json:)
232
239
  if selection.empty?
233
240
  @stderr.puts "specguard-lint: warning: selected 0 spec files — #{empty_reason(selection)}"
234
241
  @stderr.puts "specguard-lint: warning: #{selection.note}" if selection.note
235
242
  elsif !json
243
+ untracked = selection.mode == :changed ? selection.stats&.untracked.to_i : 0
236
244
  @stdout.puts "specguard-lint: checked #{selection.count} spec file#{'s' unless selection.count == 1}" \
237
245
  "#{" changed since #{selection.base}" if selection.mode == :changed}" \
238
- "#{" under #{Dir.pwd}" if selection.mode == :all}"
246
+ "#{" under #{Dir.pwd}" if selection.mode == :all}" \
247
+ "#{" including #{untracked} untracked" if untracked.positive?}"
239
248
  end
240
249
  end
241
250
 
@@ -43,6 +43,16 @@ module SpecGuard
43
43
  # There is no diff base that makes "what changed on this build" non-empty
44
44
  # there.
45
45
  #
46
+ # A **shallow** checkout — the depth-1 clone CI defaults to
47
+ # (`actions/checkout@v4`) — reaches the same thin base by a third road the
48
+ # ref-resolution above cannot see: every `DEFAULT_BRANCH_REFS` probe
49
+ # *resolves* (the fetched tip is in the clone), but the history behind it
50
+ # is not, so the merge base with the default branch is outside the clone
51
+ # and `merge-base` collapses onto HEAD. The notes below therefore consult
52
+ # `git rev-parse --is-shallow-repository` — lazily and memoized, only
53
+ # where the answer changes the message — and in a shallow clone say that,
54
+ # never "default-branch build".
55
+ #
46
56
  # That is why the load-bearing requirement is the **loud empty selection**,
47
57
  # not the base. The caller must never be unable to tell "checked 12 files,
48
58
  # found no annotations" from "checked 0 files" — {Selection} carries the
@@ -77,10 +87,32 @@ module SpecGuard
77
87
  # quotes and all, which no longer names a file — an accented spec file would
78
88
  # be silently dropped and then misreported as "nothing changed".
79
89
  #
80
- # Known limitation: `git diff` cannot see **untracked** files, so a brand
81
- # new spec file that has not been `git add`ed is not selected. In CI (a
82
- # clean checkout of a commit) that cannot arise; locally the loud empty
83
- # selection is what surfaces it.
90
+ # == Untracked files are selected too
91
+ #
92
+ # `git diff` cannot see a file that has never been `git add`ed — but "what
93
+ # this branch changed, whether or not the change is committed yet" covers a
94
+ # never-added file, and a mode that silently skipped it would ride the
95
+ # branch's newest spec past the gate: in the mixed shape every real working
96
+ # tree has (a tracked edit somewhere, the new spec still untracked), the
97
+ # selection was non-empty without the new file, so the loud-empty machinery
98
+ # never fired and the defect shipped behind a checked-count reporting
99
+ # success.
100
+ #
101
+ # So the `--changed` name set is a union: the diff's paths plus ONE
102
+ # `git ls-files --others --exclude-standard -z` call per selection, run at
103
+ # the toplevel so its repo-root-relative output flows through the same
104
+ # scoping as the diff's (from a subdirectory `ls-files` emits
105
+ # cwd-relative paths, which would defeat it). `--exclude-standard` is the
106
+ # boundary: `.gitignore`d paths — scratch directories, vendored code, build
107
+ # output — never enter the selection. The two legs are disjoint by
108
+ # construction (an untracked path is never a diff path), so the union
109
+ # cannot double-count; {Stats.untracked} says how many of the selected
110
+ # files arrived via the untracked leg, and the empty-reason ladder's
111
+ # "nothing changed against <base>" can now only fire when the working tree
112
+ # is genuinely clean. That `ls-files` call is the untracked leg's entire
113
+ # added cost, and it is scoped to this mode — the "zero new git
114
+ # invocations on the full-clone happy path" property SPGD-1035 established
115
+ # belongs to the lazy shallow probe alone, not to `--changed` as a whole.
84
116
  module FileSelector
85
117
  # `test/**/*_test.rb` rather than `**/*_test.rb`: the `test/` directory
86
118
  # is where both Rails and Minitest itself put Minitest files, and
@@ -102,9 +134,13 @@ module SpecGuard
102
134
 
103
135
  # Why an empty `--changed` selection is empty. Every count is a filter
104
136
  # this class applied, in the order it applied them, so the CLI can name
105
- # the real cause instead of guessing at the last one.
106
- Stats = Data.define(:changed, :spec_matches, :outside_root, :unreadable) do
107
- def initialize(changed: 0, spec_matches: 0, outside_root: 0, unreadable: 0)
137
+ # the real cause instead of guessing at the last one. The exception is
138
+ # `untracked`, which filters nothing: it counts the selected files that
139
+ # arrived via the untracked leg, so the report can say where a file the
140
+ # diff never saw came from (an untracked spec outside `root` is counted
141
+ # in `outside_root`, not here — this names files the run checked).
142
+ Stats = Data.define(:changed, :spec_matches, :outside_root, :unreadable, :untracked) do
143
+ def initialize(changed: 0, spec_matches: 0, outside_root: 0, unreadable: 0, untracked: 0)
108
144
  super
109
145
  end
110
146
  end
@@ -157,27 +193,42 @@ module SpecGuard
157
193
  "and no HEAD commit); pass --changed=<base> explicitly"
158
194
  end
159
195
 
160
- files, stats = changed_files(resolved, root)
196
+ # One memoized probe per run, shared by the two message branches that
197
+ # consult it (a failed diff, a thin base note).
198
+ is_shallow = shallow_probe(root)
199
+
200
+ files, stats = changed_files(resolved, root, is_shallow)
161
201
 
162
202
  Selection.new(files: files, mode: :changed, base: resolved,
163
- note: base_note(resolved, base_kind, root), stats: stats)
203
+ note: base_note(resolved, base_kind, root, is_shallow), stats: stats)
164
204
  end
165
205
 
166
206
  # Resolves git's repo-root-relative output into paths relative to `root`,
167
- # dropping (and counting) everything the two scoping rules exclude.
207
+ # dropping (and counting) everything the two scoping rules exclude. The
208
+ # name set is the union of the diff leg and the untracked leg; each name
209
+ # is tagged with its leg so {Stats.untracked} can attribute the selected
210
+ # files the diff never saw. The legs are disjoint by construction (an
211
+ # untracked path is never a diff path), so the plain union cannot
212
+ # double-count and needs no dedup.
168
213
  # @return [[Array<String>, Stats]]
169
- def changed_files(base, root)
170
- names = diff_names(base, root)
171
- specs = names.select { |name| CHANGED_PATTERNS.any? { |pattern| File.fnmatch?(pattern, name) } }
214
+ def changed_files(base, root, is_shallow)
215
+ names = diff_names(base, root, is_shallow).map { |name| [name, false] }
172
216
 
173
217
  top = toplevel(root)
218
+ names += untracked_names(top.empty? ? root : top).map { |name| [name, true] }
219
+
220
+ specs = names.select { |(name, _)|
221
+ CHANGED_PATTERNS.any? { |pattern| File.fnmatch?(pattern, name) }
222
+ }
223
+
174
224
  prefix = directory_prefix(top.empty? ? root : top)
175
225
  root_prefix = directory_prefix(real_path(root))
176
226
 
177
227
  files = []
178
228
  outside = 0
179
229
  unreadable = 0
180
- specs.each do |name|
230
+ untracked = 0
231
+ specs.each do |(name, was_untracked)|
181
232
  absolute = prefix + name
182
233
  relative = strip_prefix(absolute, root_prefix)
183
234
  if relative.nil?
@@ -186,25 +237,60 @@ module SpecGuard
186
237
  unreadable += 1
187
238
  else
188
239
  files << relative
240
+ untracked += 1 if was_untracked
189
241
  end
190
242
  end
191
243
 
192
244
  [files.sort,
193
245
  Stats.new(changed: names.length, spec_matches: specs.length,
194
- outside_root: outside, unreadable: unreadable)]
246
+ outside_root: outside, unreadable: unreadable, untracked: untracked)]
195
247
  end
196
248
 
197
249
  # `--diff-filter=d` drops deleted paths — `git diff --name-only` lists
198
250
  # them, and they then fail to open. `-z` makes the output machine-readable:
199
251
  # NUL-separated and never `core.quotePath`-quoted, so a non-ASCII path
200
252
  # survives intact and a path containing a newline cannot split a record.
201
- def diff_names(base, root)
253
+ def diff_names(base, root, is_shallow)
202
254
  out, ok = git(%W[diff -z --name-only --diff-filter=d #{base} --], root)
203
- raise UsageError, "--changed could not diff against #{base.inspect}" unless ok
255
+ unless ok
256
+ if is_shallow.call
257
+ # SPGD-1035: in a shallow checkout the overwhelmingly likely cause
258
+ # is that <base> is simply not in the clone's history (git's own
259
+ # stderr says `fatal: bad object` / `unknown revision`), and the
260
+ # fix is a fetch — a cause the bare message never named, leaving
261
+ # "bad ref" and "shallow history" indistinguishable. Non-shallow
262
+ # failures keep the original message.
263
+ raise UsageError,
264
+ "--changed could not diff against #{base.inspect}: this checkout is shallow and " \
265
+ "#{base.inspect} is not in its history — fetch it (fetch-depth: 0, or " \
266
+ "git fetch origin #{base}) or pass a base the checkout contains"
267
+ end
268
+ raise UsageError, "--changed could not diff against #{base.inspect}"
269
+ end
204
270
 
205
271
  out.split("\0").reject(&:empty?)
206
272
  end
207
273
 
274
+ # The untracked leg of `--changed`: every file git does not track, minus
275
+ # what `--exclude-standard` excludes (`.gitignore`, `.git/info/exclude`,
276
+ # the global excludes file). Run at the toplevel, where `ls-files` emits
277
+ # repo-root-relative paths — from a subdirectory it emits cwd-relative
278
+ # ones, which would break the root-scoping the diff leg's output goes
279
+ # through. `-z` for the same reason as {diff_names}: NUL-separated and
280
+ # never `core.quotePath`-quoted. Without `--directory` git lists the
281
+ # files inside an untracked directory rather than the directory itself,
282
+ # which is what the suffix filter wants. `ls-files` reads the working
283
+ # tree and the index only — no history — so the leg works in a shallow
284
+ # clone where the diff base barely exists. A failure degrades to an
285
+ # empty leg rather than failing the run: the repository was already
286
+ # verified, the tracked diff above remains the primary evidence, and a
287
+ # half-readable working tree must not kill a gate that would otherwise
288
+ # check every tracked spec.
289
+ def untracked_names(chdir)
290
+ out, ok = git(%w[ls-files --others --exclude-standard -z], chdir)
291
+ ok ? out.split("\0").reject(&:empty?) : []
292
+ end
293
+
208
294
  # The repository's top level — what `git diff`'s paths are relative to.
209
295
  # Empty when git cannot say, in which case the caller falls back to `root`
210
296
  # (the top level *is* root for the common case of running from there).
@@ -241,20 +327,63 @@ module SpecGuard
241
327
  # `git diff HEAD`, i.e. the working-tree-vs-HEAD no-op this class exists
242
328
  # to avoid. Reporting the first when the second is true would be a
243
329
  # confidently wrong explanation.
244
- def base_note(resolved, base_kind, root)
330
+ #
331
+ # SPGD-1035: a shallow (depth-limited) checkout reproduces both thin
332
+ # shapes with a third cause those two stories miss — the merge base with
333
+ # the default branch is not in the clone's history at all — so whenever
334
+ # the repo IS shallow the note tells that story and names the remedy
335
+ # (fetch the default branch, or pass a base the checkout contains),
336
+ # retiring the "default-branch build" guess for shallow repos in both
337
+ # shapes.
338
+ def base_note(resolved, base_kind, root, is_shallow)
339
+ shallow_remedy =
340
+ "fetch the default branch (fetch-depth: 0) or pass --changed=<base> naming a base this checkout contains"
245
341
  case base_kind
246
342
  when :head_fallback
343
+ if is_shallow.call
344
+ return "this checkout is a shallow (depth-limited) clone and no default-branch ref " \
345
+ "(#{DEFAULT_BRANCH_REFS.join(', ')}) is in its history, so the diff base fell " \
346
+ "back to HEAD; --changed can only select uncommitted changes here — #{shallow_remedy}"
347
+ end
348
+
247
349
  "no default-branch ref (#{DEFAULT_BRANCH_REFS.join(', ')}) could be found, so the diff base " \
248
350
  "fell back to HEAD; --changed can only select uncommitted changes here"
249
351
  when :merge_base
250
352
  head, ok = git(%w[rev-parse HEAD], root)
251
353
  return nil unless ok && head.strip == resolved
252
354
 
355
+ if is_shallow.call
356
+ return "this checkout is a shallow (depth-limited) clone, so the merge base with the " \
357
+ "default branch is not in its history and the diff base is HEAD itself — only " \
358
+ "uncommitted changes can be selected; #{shallow_remedy}"
359
+ end
360
+
253
361
  "the diff base is HEAD itself (this looks like a default-branch build), " \
254
362
  "so only uncommitted changes can be selected"
255
363
  end
256
364
  end
257
365
 
366
+ # Memoized per-run `git rev-parse --is-shallow-repository` probe.
367
+ # SPGD-1035: shallowness is consulted only in the branches where it
368
+ # changes the message (a derived base of HEAD, a failed diff), so the
369
+ # probe makes zero new git invocations of its own on the full-clone
370
+ # happy path, and the memo caps it at one per run. (That zero is the
371
+ # probe's, not the mode's: the untracked leg adds its one `ls-files`
372
+ # call to every `--changed` selection, whatever the clone's depth.) An
373
+ # unreadable answer (old git without the flag) reads as not shallow,
374
+ # keeping today's messages exactly.
375
+ # @return [Proc] zero-argument; true iff git reports a shallow repository
376
+ def shallow_probe(root)
377
+ cached = nil
378
+ lambda do
379
+ if cached.nil?
380
+ out, ok = git(%w[rev-parse --is-shallow-repository], root)
381
+ cached = ok && out.strip == "true"
382
+ end
383
+ cached
384
+ end
385
+ end
386
+
258
387
  def git_repository?(root)
259
388
  out, ok = git(%w[rev-parse --is-inside-work-tree], root)
260
389
  ok && out.strip == "true"
@@ -7,5 +7,5 @@
7
7
  # framework-scoped version constant a lie, so the number moved here and the
8
8
  # rspec module now points at it for back-compat.
9
9
  module SpecGuard
10
- VERSION = "0.3.8"
10
+ VERSION = "0.3.10"
11
11
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: specguard-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.8
4
+ version: 0.3.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - specguard Agent
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-09 00:00:00.000000000 Z
11
+ date: 2026-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json