quick_check 0.1.0 → 0.1.3

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: 896e5b8930fe9d6f9c090ef32c1bbc70a57a05d415c7ad6996e5a4e211868963
4
- data.tar.gz: ae0e8f1831e674357081df078d68cfdbf3966c22d09f6c826becaa7d6a21bd90
3
+ metadata.gz: e665ad565d365abca56c8521560fb97dd507683c8eec6d7c81084b2b393cf2d0
4
+ data.tar.gz: e82c198675cbc2aa956395d6b327088db5f5d4ad85f96a39c5abafb6830f57e4
5
5
  SHA512:
6
- metadata.gz: cddc454b92ad6e3c2c1f9afe08dab40305c9ee29bfe91d35a44fe578b7c52bb27cc09dbd1806c13d6cb9350bb4e945a0e2e1c24c757bbb005865739b2cd9281f
7
- data.tar.gz: 7952bbeb5140d87b51b189b464ee7309367a49fc687e099bdcd124b9d5744b0914735d0336b36d500a6dbe781b2bae5101101519a9446af319f2bbb442256fc4
6
+ metadata.gz: 60f09a0569f3d80c90d95b41bc1d289cf136331bdf639e50d3c85931262488e9f6a5edc9678241b6ed1b9c570bfd8791031b8056acd1b51f7769cd85a3da3505
7
+ data.tar.gz: 2e38a08ee04981404e46b4099882a07927397aa66a9961704f480bb30b48cdb691af989e5c48cfb84d17475767e81309b8e3aed9801b616840b45ec2fbe9556b
data/README.md CHANGED
@@ -27,6 +27,7 @@ qc
27
27
  - Staged (index) changes
28
28
  - Untracked files (new specs not yet added)
29
29
  - All committed changes on your branch vs base (`main`/`master`)
30
+ - Renames and copies are tracked so moved tests still run
30
31
  - You can disable branch commits with `--no-committed`
31
32
  - Auto-detects base branch (`main` or `master`) or configure via `.quick_check.yml`
32
33
  - Auto-detects framework and command:
@@ -99,10 +100,10 @@ If no config is present, `qc` will use the first existing branch among `main` or
99
100
 
100
101
  ## How it works
101
102
 
102
- - Unstaged: `git diff --name-only --diff-filter=ACMR`
103
- - Staged: `git diff --name-only --cached --diff-filter=ACMR`
103
+ - Unstaged: `git diff --name-only -M -C --diff-filter=ACMR`
104
+ - Staged: `git diff --name-only --cached -M -C --diff-filter=ACMR`
104
105
  - Untracked: `git ls-files --others --exclude-standard`
105
- - Committed vs base: `git diff --name-only --diff-filter=ACMR <base>...HEAD`
106
+ - Committed vs base: `git diff --name-only -M -C --diff-filter=ACMR <base>...HEAD`
106
107
 
107
108
  Files are filtered to `spec/**/*_spec.rb` and/or `test/**/*_test.rb`, de-duplicated, sorted, and then:
108
109
 
@@ -126,39 +126,74 @@ module QuickCheck
126
126
  files = []
127
127
 
128
128
  if @options[:include_unstaged]
129
- files.concat(git_diff_name_only(["--name-only", "--diff-filter=ACMR"]))
129
+ files.concat(git_diff_name_only(["--name-only", "-M", "-C", "--diff-filter=ACMR"]))
130
130
  files.concat(git_untracked_files)
131
131
  end
132
132
 
133
133
  if @options[:include_staged]
134
- files.concat(git_diff_name_only(["--name-only", "--cached", "--diff-filter=ACMR"]))
134
+ files.concat(git_diff_name_only(["--name-only", "--cached", "-M", "-C", "--diff-filter=ACMR"]))
135
135
  end
136
136
 
137
137
  if @options[:include_committed_diff]
138
138
  current_branch = git_current_branch
139
139
  if current_branch && base_branch && current_branch != base_branch
140
- # Include files changed on this branch vs base
141
- range = diff_range_against_base(base_branch)
142
- files.concat(git_diff_name_only(["--name-only", "--diff-filter=ACMR", range])) if range
140
+ upstream_branch = git_upstream_branch(current_branch)
141
+ # Prefer upstream tracking branch to correctly handle rebases (only shows local changes)
142
+ if upstream_branch && upstream_has_differences?(upstream_branch)
143
+ changed_files = diff_range_against_upstream(upstream_branch)
144
+ files.concat(changed_files) if changed_files
145
+ elsif !upstream_branch
146
+ # Fall back to base branch if no upstream exists
147
+ changed_files = diff_range_against_base(base_branch)
148
+ files.concat(changed_files) if changed_files
149
+ end
143
150
  end
144
151
  end
145
152
 
146
153
  files = files.compact.uniq
147
154
  rspec_specs = files.select { |f| f.match?(%r{\Aspec/.+_spec\.rb\z}) }
155
+ rspec_specs += infer_rspec_from_source(files)
148
156
  minitest_tests = files.select { |f| f.match?(%r{\Atest/.+_test\.rb\z}) }
149
157
 
150
- # Infer tests from source changes (e.g., app/models/user.rb -> spec/models/user_spec.rb)
151
- source_files = files.reject { |f| f.match?(%r{\A(spec/|test/)}) }
152
- unless source_files.empty?
153
- inferred_rspec = source_files.flat_map { |src| infer_rspec_paths_for_source(src) }
154
- inferred_minitest = source_files.flat_map { |src| infer_minitest_paths_for_source(src) }
155
- rspec_specs.concat(inferred_rspec)
156
- minitest_tests.concat(inferred_minitest)
157
- end
158
-
159
158
  { rspec: rspec_specs.uniq, minitest: minitest_tests.uniq }
160
159
  end
161
160
 
161
+ def infer_rspec_from_source(files)
162
+ candidates = []
163
+ files.each do |path|
164
+ next unless path.end_with?(".rb")
165
+
166
+ if path =~ %r{\Aapp/models/(.+)\.rb\z}
167
+ spec_path = File.join("spec", "models", "#{$1}_spec.rb")
168
+ candidates << spec_path if File.file?(spec_path)
169
+ next
170
+ end
171
+
172
+ if path =~ %r{\Aapp/controllers/(.+?)(?:_controller)?\.rb\z}
173
+ controller_path = Regexp.last_match(1)
174
+ req_base = File.join("spec", "requests", controller_path)
175
+ req_variants = [
176
+ "#{req_base}_spec.rb",
177
+ "#{req_base}_controller_spec.rb"
178
+ ].select { |p| File.file?(p) }
179
+ if req_variants.any?
180
+ candidates.concat(req_variants)
181
+ else
182
+ ctrl_spec = File.join("spec", "controllers", "#{controller_path}_controller_spec.rb")
183
+ candidates << ctrl_spec if File.file?(ctrl_spec)
184
+ end
185
+ next
186
+ end
187
+
188
+ if path =~ %r{\Alib/(.+)\.rb\z}
189
+ spec_path = File.join("spec", "lib", "#{$1}_spec.rb")
190
+ candidates << spec_path if File.file?(spec_path)
191
+ next
192
+ end
193
+ end
194
+ candidates
195
+ end
196
+
162
197
  def ensure_git_repo!
163
198
  run_cmd(["git", "rev-parse", "--is-inside-work-tree"]).tap do |ok, out, _err|
164
199
  unless ok && out.to_s.strip == "true"
@@ -205,12 +240,17 @@ module QuickCheck
205
240
  end
206
241
 
207
242
  def branch_exists?(name)
243
+ local_branch_exists?(name) || remote_branch_exists?(name)
244
+ end
245
+
246
+ def local_branch_exists?(name)
208
247
  ok, _out, _err = run_cmd(["git", "show-ref", "--verify", "--quiet", "refs/heads/#{name}"])
209
- return true if ok
248
+ ok
249
+ end
210
250
 
211
- # fall back to remote branch
212
- ok, _out, _err = run_cmd(["git", "ls-remote", "--heads", "origin", name])
213
- ok && !_out.to_s.strip.empty?
251
+ def remote_branch_exists?(name)
252
+ ok, out, _err = run_cmd(["git", "ls-remote", "--heads", "origin", name])
253
+ ok && !out.to_s.strip.empty?
214
254
  end
215
255
 
216
256
  def git_current_branch
@@ -223,11 +263,50 @@ module QuickCheck
223
263
  ok ? out.to_s.strip : nil
224
264
  end
225
265
 
266
+ def git_upstream_branch(branch)
267
+ # Get the upstream tracking branch for the current branch
268
+ ok, out, _err = run_cmd(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "#{branch}@{u}"])
269
+ if ok && !out.to_s.strip.empty?
270
+ upstream = out.to_s.strip
271
+ # Verify the upstream branch actually exists
272
+ ok_check, _out_check, _err_check = run_cmd(["git", "rev-parse", "--verify", "--quiet", upstream])
273
+ return upstream if ok_check
274
+ end
275
+ nil
276
+ rescue StandardError
277
+ nil
278
+ end
279
+
280
+ def upstream_has_differences?(upstream)
281
+ # Only use upstream if it has local commits (avoids testing already-pushed changes)
282
+ ok, out, _err = run_cmd(["git", "rev-list", "--count", "#{upstream}..HEAD"])
283
+ return false unless ok
284
+ out.to_s.strip.to_i > 0
285
+ end
286
+
287
+ def diff_range_against_upstream(upstream)
288
+ files = git_log_first_parent_files(upstream, "HEAD")
289
+ files.empty? ? nil : files
290
+ end
291
+
226
292
  def diff_range_against_base(base)
227
- return nil unless branch_exists?(base)
228
- # If upstream exists, prefer merge-base to HEAD to include all branch commits
229
- # The symmetric range base...HEAD ensures we include commits unique to the branch
230
- "#{base}...HEAD"
293
+ base_ref = if local_branch_exists?(base)
294
+ base
295
+ elsif remote_branch_exists?(base)
296
+ "origin/#{base}"
297
+ else
298
+ return nil
299
+ end
300
+ files = git_log_first_parent_files(base_ref, "HEAD")
301
+ files.empty? ? nil : files
302
+ end
303
+
304
+ def git_log_first_parent_files(base_ref, head_ref)
305
+ # Use --first-parent to exclude merge commits, only showing changes from direct branch commits
306
+ cmd = ["git", "log", "--first-parent", "--name-only", "--diff-filter=ACMR", "--format=", "#{base_ref}..#{head_ref}"]
307
+ ok, out, _err = run_cmd(cmd)
308
+ return [] unless ok
309
+ out.split("\n").map(&:strip).reject(&:empty?).uniq
231
310
  end
232
311
 
233
312
  def git_diff_name_only(args)
@@ -245,52 +324,6 @@ module QuickCheck
245
324
  out.split("\n").map(&:strip).reject(&:empty?)
246
325
  end
247
326
 
248
- def infer_rspec_paths_for_source(source_path)
249
- candidates = []
250
- if source_path.start_with?("app/")
251
- rel = source_path.sub(/^app\//, "")
252
- # Prefer request specs for controllers
253
- if rel.start_with?("controllers/")
254
- sub_rel = rel.sub(/^controllers\//, "")
255
- dir = File.dirname(sub_rel)
256
- dir = "" if dir == "."
257
- base_with_controller = File.basename(sub_rel, ".rb")
258
- base_without_controller = base_with_controller.sub(/_controller\z/, "")
259
- # Common conventions for request specs
260
- candidates << File.join("spec", "requests", dir, "#{base_without_controller}_spec.rb")
261
- candidates << File.join("spec", "requests", dir, "#{base_with_controller}_spec.rb")
262
- end
263
- candidates << File.join("spec", rel).sub(/\.rb\z/, "_spec.rb")
264
- elsif source_path.start_with?("lib/")
265
- rel = source_path.sub(/^lib\//, "")
266
- candidates << File.join("spec", "lib", rel).sub(/\.rb\z/, "_spec.rb")
267
- end
268
-
269
- candidates.select { |p| File.file?(p) }
270
- end
271
-
272
- def infer_minitest_paths_for_source(source_path)
273
- candidates = []
274
- if source_path.start_with?("app/")
275
- rel = source_path.sub(/^app\//, "")
276
- # Prefer integration/request-style tests for controllers when present
277
- if rel.start_with?("controllers/")
278
- sub_rel = rel.sub(/^controllers\//, "")
279
- dir = File.dirname(sub_rel)
280
- dir = "" if dir == "."
281
- base = File.basename(sub_rel, ".rb").sub(/_controller\z/, "")
282
- integration_test = File.join("test", "integration", dir, "#{base}_test.rb")
283
- candidates << integration_test
284
- end
285
- candidates << File.join("test", rel).sub(/\.rb\z/, "_test.rb")
286
- elsif source_path.start_with?("lib/")
287
- rel = source_path.sub(/^lib\//, "")
288
- candidates << File.join("test", "lib", rel).sub(/\.rb\z/, "_test.rb")
289
- end
290
-
291
- candidates.select { |p| File.file?(p) }
292
- end
293
-
294
327
  def build_command_for(framework, files)
295
328
  return (@options[:custom_command] + files) if @options[:custom_command]
296
329
 
@@ -310,13 +343,11 @@ module QuickCheck
310
343
  end
311
344
 
312
345
  def rails_available?
313
- bin_rails = File.join(Dir.pwd, "bin", "rails")
314
- File.executable?(bin_rails) || File.file?(bin_rails) || gemfile_includes?("rails")
346
+ File.executable?(File.join(Dir.pwd, "bin", "rails")) || gemfile_includes?("rails")
315
347
  end
316
348
 
317
349
  def rails_cmd
318
- bin_rails = File.join(Dir.pwd, "bin", "rails")
319
- if File.executable?(bin_rails) || File.file?(bin_rails)
350
+ if File.executable?(File.join(Dir.pwd, "bin", "rails"))
320
351
  [File.join("bin", "rails")]
321
352
  else
322
353
  ["bundle", "exec", "rails"]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module QuickCheck
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.3"
5
5
  end
@@ -53,7 +53,7 @@ RSpec.describe QuickCheck::CLI do
53
53
 
54
54
  it "runs rspec for changed rspec files" do
55
55
  stubs = base_git_stubs.merge(
56
- "git diff --name-only --diff-filter=ACMR" => { stdout: "spec/models/user_spec.rb\n" }
56
+ "git diff --name-only -M -C --diff-filter=ACMR" => { stdout: "spec/models/user_spec.rb\n" }
57
57
  )
58
58
 
59
59
  status, out, _err = run_cli(["--dry-run"], git_outputs: stubs)
@@ -63,7 +63,7 @@ RSpec.describe QuickCheck::CLI do
63
63
 
64
64
  it "infers rspec from app source change" do
65
65
  stubs = base_git_stubs.merge(
66
- "git diff --name-only --diff-filter=ACMR" => { stdout: "app/models/user.rb\n" }
66
+ "git diff --name-only -M -C --diff-filter=ACMR" => { stdout: "app/models/user.rb\n" }
67
67
  )
68
68
  status, out, _err = run_cli(["--dry-run"], git_outputs: stubs, existing_files: [
69
69
  File.join("spec", "models", "user_spec.rb")
@@ -74,7 +74,7 @@ RSpec.describe QuickCheck::CLI do
74
74
 
75
75
  it "maps controller to request spec with both base names" do
76
76
  stubs = base_git_stubs.merge(
77
- "git diff --name-only --diff-filter=ACMR" => { stdout: "app/controllers/account/users_controller.rb\n" }
77
+ "git diff --name-only -M -C --diff-filter=ACMR" => { stdout: "app/controllers/account/users_controller.rb\n" }
78
78
  )
79
79
  existing = [
80
80
  File.join("spec", "requests", "account", "users_spec.rb"),
@@ -87,7 +87,7 @@ RSpec.describe QuickCheck::CLI do
87
87
 
88
88
  it "falls back to controller spec when no request spec exists" do
89
89
  stubs = base_git_stubs.merge(
90
- "git diff --name-only --diff-filter=ACMR" => { stdout: "app/controllers/home_controller.rb\n" }
90
+ "git diff --name-only -M -C --diff-filter=ACMR" => { stdout: "app/controllers/home_controller.rb\n" }
91
91
  )
92
92
  existing = [File.join("spec", "controllers", "home_controller_spec.rb")]
93
93
  status, out, _err = run_cli(["--dry-run"], git_outputs: stubs, existing_files: existing)
@@ -97,7 +97,7 @@ RSpec.describe QuickCheck::CLI do
97
97
 
98
98
  it "runs minitest through rails test when test files change and rails present" do
99
99
  stubs = base_git_stubs.merge(
100
- "git diff --name-only --diff-filter=ACMR" => { stdout: "test/models/user_test.rb\n" }
100
+ "git diff --name-only -M -C --diff-filter=ACMR" => { stdout: "test/models/user_test.rb\n" }
101
101
  )
102
102
  allow_any_instance_of(QuickCheck::CLI).to receive(:rails_available?).and_return(true)
103
103
  status, out, _err = run_cli(["--dry-run"], git_outputs: stubs, existing_files: [File.join("bin", "rails")])
@@ -107,11 +107,59 @@ RSpec.describe QuickCheck::CLI do
107
107
 
108
108
  it "runs per-file minitest when no rails" do
109
109
  stubs = base_git_stubs.merge(
110
- "git diff --name-only --diff-filter=ACMR" => { stdout: "test/models/user_test.rb\n" }
110
+ "git diff --name-only -M -C --diff-filter=ACMR" => { stdout: "test/models/user_test.rb\n" }
111
111
  )
112
112
 
113
113
  status, out, _err = run_cli(["--dry-run"], git_outputs: stubs)
114
114
  expect(out.lines.map(&:strip)).to include("ruby -I test test/models/user_test.rb")
115
115
  expect(status).to eq(0)
116
116
  end
117
+
118
+ it "uses first-parent to handle rebases correctly" do
119
+ stubs = base_git_stubs.merge(
120
+ "git show-ref --verify --quiet refs/heads/main" => { success: true },
121
+ "git log --first-parent --name-only --diff-filter=ACMR --format= main..HEAD" => { stdout: "spec/my_feature_spec.rb\n" }
122
+ )
123
+
124
+ status, out, _err = run_cli(["--base", "main", "--dry-run", "--no-unstaged", "--no-staged"], git_outputs: stubs)
125
+ expect(status).to eq(0)
126
+ expect(out).to include("bundle exec rspec spec/my_feature_spec.rb")
127
+ end
128
+
129
+ it "prefers upstream branch over base branch when upstream has differences" do
130
+ stubs = base_git_stubs.merge(
131
+ "git rev-parse --abbrev-ref --symbolic-full-name feature@{u}" => { stdout: "origin/feature\n" },
132
+ "git rev-parse --verify --quiet origin/feature" => { success: true },
133
+ "git rev-list --count origin/feature..HEAD" => { stdout: "5\n" },
134
+ "git log --first-parent --name-only --diff-filter=ACMR --format= origin/feature..HEAD" => { stdout: "spec/my_local_change_spec.rb\n" }
135
+ )
136
+
137
+ status, out, _err = run_cli(["--base", "main", "--dry-run", "--no-unstaged", "--no-staged"], git_outputs: stubs)
138
+ expect(status).to eq(0)
139
+ expect(out).to include("bundle exec rspec spec/my_local_change_spec.rb")
140
+ end
141
+
142
+ it "does not include committed changes when upstream is in sync" do
143
+ stubs = base_git_stubs.merge(
144
+ "git rev-parse --abbrev-ref --symbolic-full-name feature@{u}" => { stdout: "origin/feature\n" },
145
+ "git rev-parse --verify --quiet origin/feature" => { success: true },
146
+ "git rev-list --count origin/feature..HEAD" => { stdout: "0\n" }
147
+ )
148
+
149
+ status, out, _err = run_cli(["--base", "main", "--dry-run", "--no-unstaged", "--no-staged"], git_outputs: stubs)
150
+ expect(status).to eq(0)
151
+ expect(out).to include("No changed/added test files detected.")
152
+ end
153
+
154
+ it "falls back to base branch when no upstream exists" do
155
+ stubs = base_git_stubs.merge(
156
+ "git rev-parse --abbrev-ref --symbolic-full-name feature@{u}" => { success: false, exitstatus: 128 },
157
+ "git show-ref --verify --quiet refs/heads/main" => { success: true },
158
+ "git log --first-parent --name-only --diff-filter=ACMR --format= main..HEAD" => { stdout: "spec/my_feature_spec.rb\n" }
159
+ )
160
+
161
+ status, out, _err = run_cli(["--base", "main", "--dry-run", "--no-unstaged", "--no-staged"], git_outputs: stubs)
162
+ expect(status).to eq(0)
163
+ expect(out).to include("bundle exec rspec spec/my_feature_spec.rb")
164
+ end
117
165
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quick_check
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kasvit
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-08-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
12
  description: Adds the `qc` command to run only changed or newly added RSpec files
14
13
  from uncommitted changes and vs base branch (main/master).
@@ -32,7 +31,6 @@ homepage: https://github.com/kasvit/quick_check
32
31
  licenses:
33
32
  - MIT
34
33
  metadata: {}
35
- post_install_message:
36
34
  rdoc_options: []
37
35
  require_paths:
38
36
  - lib
@@ -47,8 +45,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
47
45
  - !ruby/object:Gem::Version
48
46
  version: '0'
49
47
  requirements: []
50
- rubygems_version: 3.1.6
51
- signing_key:
48
+ rubygems_version: 3.6.9
52
49
  specification_version: 4
53
50
  summary: Run changed/added RSpec specs quickly
54
51
  test_files: []