bite-check 1.0.0__tar.gz → 1.0.2__tar.gz

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.
Files changed (64) hide show
  1. {bite_check-1.0.0 → bite_check-1.0.2}/PKG-INFO +18 -4
  2. {bite_check-1.0.0 → bite_check-1.0.2}/README.md +17 -3
  3. {bite_check-1.0.0 → bite_check-1.0.2}/bite.py +27 -5
  4. {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/PKG-INFO +18 -4
  5. {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/SOURCES.txt +3 -0
  6. {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/entry_points.txt +1 -0
  7. {bite_check-1.0.0 → bite_check-1.0.2}/live.py +6 -1
  8. {bite_check-1.0.0 → bite_check-1.0.2}/pyproject.toml +2 -1
  9. {bite_check-1.0.0 → bite_check-1.0.2}/recent.py +97 -10
  10. bite_check-1.0.2/tests/test_first_run.py +88 -0
  11. bite_check-1.0.2/tests/test_hang.py +90 -0
  12. bite_check-1.0.2/tests/test_progress.py +81 -0
  13. {bite_check-1.0.0 → bite_check-1.0.2}/LICENSE +0 -0
  14. {bite_check-1.0.0 → bite_check-1.0.2}/ask.py +0 -0
  15. {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/dependency_links.txt +0 -0
  16. {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/requires.txt +0 -0
  17. {bite_check-1.0.0 → bite_check-1.0.2}/bite_check.egg-info/top_level.txt +0 -0
  18. {bite_check-1.0.0 → bite_check-1.0.2}/bite_mcp.py +0 -0
  19. {bite_check-1.0.0 → bite_check-1.0.2}/repo.py +0 -0
  20. {bite_check-1.0.0 → bite_check-1.0.2}/setup.cfg +0 -0
  21. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_action.py +0 -0
  22. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_any_encoding.py +0 -0
  23. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_ask.py +0 -0
  24. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_bite_claim.py +0 -0
  25. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_bot.py +0 -0
  26. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_claim_command.py +0 -0
  27. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_comeback.py +0 -0
  28. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_comment_size.py +0 -0
  29. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_copy_names.py +0 -0
  30. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_deleted_files.py +0 -0
  31. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_doctests.py +0 -0
  32. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_fail_comment.py +0 -0
  33. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_go_other_systems.py +0 -0
  34. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_huge_files.py +0 -0
  35. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_java_kotlin.py +0 -0
  36. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_jobs_memory.py +0 -0
  37. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_json.py +0 -0
  38. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_later_test_near_tidy.py +0 -0
  39. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_live.py +0 -0
  40. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_mcp.py +0 -0
  41. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_merge_shards.py +0 -0
  42. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_most_wanted.py +0 -0
  43. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_names.py +0 -0
  44. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_no_pings.py +0 -0
  45. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_node_copies.py +0 -0
  46. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_odd_inputs.py +0 -0
  47. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_odd_repos.py +0 -0
  48. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_open_pr.py +0 -0
  49. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_passing_names.py +0 -0
  50. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_paste_paths.py +0 -0
  51. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_pr_checklist.py +0 -0
  52. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_publish_badge.py +0 -0
  53. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_rust_doc_examples.py +0 -0
  54. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_rust_unit_tests.py +0 -0
  55. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_score.py +0 -0
  56. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_score_trend.py +0 -0
  57. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_self_check.py +0 -0
  58. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_selftest.py +0 -0
  59. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_settings_file.py +0 -0
  60. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_shallow.py +0 -0
  61. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_shard.py +0 -0
  62. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_submodules.py +0 -0
  63. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_tricks.py +0 -0
  64. {bite_check-1.0.0 → bite_check-1.0.2}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bite-check
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Checks whether the "fixed" in your coding agent's recent commits is actually true.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/connabyss/bite
@@ -39,6 +39,20 @@ Bite checks whether an AI coding agent's "fixed" is actually true.
39
39
 
40
40
  <img alt="bite --demo: one fix is noticed by a test, the other has no test at all" src="https://raw.githubusercontent.com/connabyss/bite/main/assets/bite-demo.svg" width="760">
41
41
 
42
+ **Try it on your own repo in one line.** It's free, needs no key, and never changes your files:
43
+
44
+ ```
45
+ cd your-repo
46
+ uvx bite-check --free
47
+ ```
48
+
49
+ No [uv](https://docs.astral.sh/uv/)? `pipx run bite-check --free` does the same with [pipx](https://pipx.pypa.io).
50
+ If your commits never say "fix", add `--any` to check every recent change. To keep Bite installed, see
51
+ [Try it in a minute](#try-it-in-a-minute).
52
+
53
+ **Here for Hack Week or your first open-source PR?** These [good first issues](https://github.com/connabyss/bite/labels/good%20first%20issue)
54
+ are small, and each one says where to look and when it's done. Comment on one to claim it.
55
+
42
56
  ## Why you need this
43
57
 
44
58
  When an AI fixes a bug for you, it often says "Fixed, and I added a test." If you don't read code, you can't tell
@@ -284,7 +298,7 @@ jobs:
284
298
  # install what your tests need here, e.g. for Python:
285
299
  - uses: actions/setup-python@v7
286
300
  - run: python -m pip install pytest -r requirements.txt
287
- - uses: connabyss/bite@v1.0.0
301
+ - uses: connabyss/bite@v1.0.2
288
302
  ```
289
303
 
290
304
  Options go under `with:`: `args` (extra `bite` options, e.g. `--serious`), `max-commits` (default 20),
@@ -298,7 +312,7 @@ When no test noticed a change, Bite can write that test for you. Add your Anthro
298
312
  named `ANTHROPIC_API_KEY`, then:
299
313
 
300
314
  ```yaml
301
- - uses: connabyss/bite@v1.0.0
315
+ - uses: connabyss/bite@v1.0.2
302
316
  with:
303
317
  write-tests: true
304
318
  anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -415,7 +429,7 @@ jobs:
415
429
  with:
416
430
  fetch-depth: 0
417
431
  # install what your tests need here, as in the pull request check
418
- - uses: connabyss/bite@v1.0.0
432
+ - uses: connabyss/bite@v1.0.2
419
433
  with:
420
434
  badge-branch: bite-badge
421
435
  max-commits: 10
@@ -13,6 +13,20 @@ Bite checks whether an AI coding agent's "fixed" is actually true.
13
13
 
14
14
  <img alt="bite --demo: one fix is noticed by a test, the other has no test at all" src="https://raw.githubusercontent.com/connabyss/bite/main/assets/bite-demo.svg" width="760">
15
15
 
16
+ **Try it on your own repo in one line.** It's free, needs no key, and never changes your files:
17
+
18
+ ```
19
+ cd your-repo
20
+ uvx bite-check --free
21
+ ```
22
+
23
+ No [uv](https://docs.astral.sh/uv/)? `pipx run bite-check --free` does the same with [pipx](https://pipx.pypa.io).
24
+ If your commits never say "fix", add `--any` to check every recent change. To keep Bite installed, see
25
+ [Try it in a minute](#try-it-in-a-minute).
26
+
27
+ **Here for Hack Week or your first open-source PR?** These [good first issues](https://github.com/connabyss/bite/labels/good%20first%20issue)
28
+ are small, and each one says where to look and when it's done. Comment on one to claim it.
29
+
16
30
  ## Why you need this
17
31
 
18
32
  When an AI fixes a bug for you, it often says "Fixed, and I added a test." If you don't read code, you can't tell
@@ -258,7 +272,7 @@ jobs:
258
272
  # install what your tests need here, e.g. for Python:
259
273
  - uses: actions/setup-python@v7
260
274
  - run: python -m pip install pytest -r requirements.txt
261
- - uses: connabyss/bite@v1.0.0
275
+ - uses: connabyss/bite@v1.0.2
262
276
  ```
263
277
 
264
278
  Options go under `with:`: `args` (extra `bite` options, e.g. `--serious`), `max-commits` (default 20),
@@ -272,7 +286,7 @@ When no test noticed a change, Bite can write that test for you. Add your Anthro
272
286
  named `ANTHROPIC_API_KEY`, then:
273
287
 
274
288
  ```yaml
275
- - uses: connabyss/bite@v1.0.0
289
+ - uses: connabyss/bite@v1.0.2
276
290
  with:
277
291
  write-tests: true
278
292
  anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -389,7 +403,7 @@ jobs:
389
403
  with:
390
404
  fetch-depth: 0
391
405
  # install what your tests need here, as in the pull request check
392
- - uses: connabyss/bite@v1.0.0
406
+ - uses: connabyss/bite@v1.0.2
393
407
  with:
394
408
  badge-branch: bite-badge
395
409
  max-commits: 10
@@ -659,11 +659,30 @@ def locked_run(cfg: Config, cmd: str, cwd: Path, lane: str = ""):
659
659
  if why:
660
660
  return None, f"not run: {why}"
661
661
  try:
662
- return run_cmd(contain(cfg, cmd, cwd), cwd, cfg.test_timeout)
662
+ return run_cmd(contain(cfg, cmd, cwd), cwd, run_limit(cfg))
663
663
  finally:
664
664
  lock.release()
665
665
 
666
666
 
667
+ # A run with a change taken out that takes this many times as long as the run with it in (and at least
668
+ # HANG_FLOOR seconds) is a hang: an unbounded loop the change had bounded, say. Waiting out test_timeout for it
669
+ # (10 minutes by default, and the flaky rerun and the piece check wait again) stalled whole runs.
670
+ HANG_TIMES, HANG_FLOOR = 5, 60
671
+
672
+
673
+ def run_limit(cfg) -> int:
674
+ """How long one test run may take: test_timeout, or less once cap_hang has seen how long the tests take."""
675
+ cap = getattr(cfg, "hang_cap", 0)
676
+ return min(cfg.test_timeout, cap) if cap else cfg.test_timeout
677
+
678
+
679
+ def cap_hang(cfg, now: dict) -> None:
680
+ """The tests took now["secs"] with the change in. Later runs on this copy (the change taken out) stop at
681
+ HANG_TIMES that, so a change whose removal makes the tests hang shows as noticed in a minute or two."""
682
+ if now.get("secs") is not None:
683
+ cfg.hang_cap = max(HANG_FLOOR, int(HANG_TIMES * now["secs"]) + 1)
684
+
685
+
667
686
  EXTRA_ENV = {} # e.g. BITE_PERSONA and BITE_OUT for a crowd run
668
687
 
669
688
 
@@ -712,11 +731,14 @@ def exit_why(cmd: str, rc: int) -> str:
712
731
 
713
732
  def run_tests(cfg: Config, folder: Path) -> dict:
714
733
  cmd = tests_cmd(cfg).replace("{project}", str(folder)).replace("{seed}", SEED[0])
734
+ start = time.monotonic()
715
735
  rc, out = locked_run(cfg, cmd, folder)
736
+ secs = round(time.monotonic() - start, 1)
716
737
  if rc is None:
717
- why = out if out.startswith("not run:") else f"timed out after {cfg.test_timeout}s (test_command must exit on its own)"
718
- return {"ok": False, "why": why,
719
- "fails": [], "count": None, "out": out}
738
+ hung = not out.startswith("not run:")
739
+ why = f"timed out after {run_limit(cfg)}s (test_command must exit on its own)" if hung else out
740
+ return {"ok": False, "why": why, "fails": [], "count": None, "out": out, "hung": hung,
741
+ "secs": secs if hung else None}
720
742
  fails = [ln.strip() for ln in out.splitlines() if re.search(cfg.fail_pattern, ln) and not PASSED_LINE.match(ln)]
721
743
  count = None
722
744
  if cfg.count_pattern:
@@ -726,7 +748,7 @@ def run_tests(cfg: Config, folder: Path) -> dict:
726
748
  count = auto_count(out)
727
749
  ok = rc in cfg.ok_codes and not fails and (count is None or count > 0)
728
750
  why = (exit_why(cmd, rc) if rc else "") or (fails[0][:160] if fails else "") or ("no tests ran" if count == 0 else "")
729
- return {"ok": ok, "why": why, "fails": fails, "count": count, "out": out}
751
+ return {"ok": ok, "why": why, "fails": fails, "count": count, "out": out, "secs": secs}
730
752
 
731
753
 
732
754
  def safe_write(root: Path, target: Path, data: bytes):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bite-check
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Checks whether the "fixed" in your coding agent's recent commits is actually true.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/connabyss/bite
@@ -39,6 +39,20 @@ Bite checks whether an AI coding agent's "fixed" is actually true.
39
39
 
40
40
  <img alt="bite --demo: one fix is noticed by a test, the other has no test at all" src="https://raw.githubusercontent.com/connabyss/bite/main/assets/bite-demo.svg" width="760">
41
41
 
42
+ **Try it on your own repo in one line.** It's free, needs no key, and never changes your files:
43
+
44
+ ```
45
+ cd your-repo
46
+ uvx bite-check --free
47
+ ```
48
+
49
+ No [uv](https://docs.astral.sh/uv/)? `pipx run bite-check --free` does the same with [pipx](https://pipx.pypa.io).
50
+ If your commits never say "fix", add `--any` to check every recent change. To keep Bite installed, see
51
+ [Try it in a minute](#try-it-in-a-minute).
52
+
53
+ **Here for Hack Week or your first open-source PR?** These [good first issues](https://github.com/connabyss/bite/labels/good%20first%20issue)
54
+ are small, and each one says where to look and when it's done. Comment on one to claim it.
55
+
42
56
  ## Why you need this
43
57
 
44
58
  When an AI fixes a bug for you, it often says "Fixed, and I added a test." If you don't read code, you can't tell
@@ -284,7 +298,7 @@ jobs:
284
298
  # install what your tests need here, e.g. for Python:
285
299
  - uses: actions/setup-python@v7
286
300
  - run: python -m pip install pytest -r requirements.txt
287
- - uses: connabyss/bite@v1.0.0
301
+ - uses: connabyss/bite@v1.0.2
288
302
  ```
289
303
 
290
304
  Options go under `with:`: `args` (extra `bite` options, e.g. `--serious`), `max-commits` (default 20),
@@ -298,7 +312,7 @@ When no test noticed a change, Bite can write that test for you. Add your Anthro
298
312
  named `ANTHROPIC_API_KEY`, then:
299
313
 
300
314
  ```yaml
301
- - uses: connabyss/bite@v1.0.0
315
+ - uses: connabyss/bite@v1.0.2
302
316
  with:
303
317
  write-tests: true
304
318
  anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -415,7 +429,7 @@ jobs:
415
429
  with:
416
430
  fetch-depth: 0
417
431
  # install what your tests need here, as in the pull request check
418
- - uses: connabyss/bite@v1.0.0
432
+ - uses: connabyss/bite@v1.0.2
419
433
  with:
420
434
  badge-branch: bite-badge
421
435
  max-commits: 10
@@ -25,7 +25,9 @@ tests/test_copy_names.py
25
25
  tests/test_deleted_files.py
26
26
  tests/test_doctests.py
27
27
  tests/test_fail_comment.py
28
+ tests/test_first_run.py
28
29
  tests/test_go_other_systems.py
30
+ tests/test_hang.py
29
31
  tests/test_huge_files.py
30
32
  tests/test_java_kotlin.py
31
33
  tests/test_jobs_memory.py
@@ -44,6 +46,7 @@ tests/test_open_pr.py
44
46
  tests/test_passing_names.py
45
47
  tests/test_paste_paths.py
46
48
  tests/test_pr_checklist.py
49
+ tests/test_progress.py
47
50
  tests/test_publish_badge.py
48
51
  tests/test_rust_doc_examples.py
49
52
  tests/test_rust_unit_tests.py
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  bite = recent:main
3
+ bite-check = recent:main
@@ -112,12 +112,14 @@ def check(root: Path, base: str = "HEAD") -> dict:
112
112
  return {**r, "status": "cant", "why": f"the tests fail with the change in ({now['why']})"}
113
113
  if recent.ran_nothing(bite.tests_cmd(fcfg), now["out"]):
114
114
  return {**r, "status": "cant", "why": "no tests ran"}
115
+ bite.cap_hang(fcfg, now)
115
116
  recent.take_out(root, {"code": code, "before": base}, folder)
116
117
  out = bite.run_tests(fcfg, folder)
117
118
  if out["ok"]:
118
119
  test_code = [n for n in tests if Path(n).suffix.lower() in repo.CODE_SUFFIXES]
119
120
  return {**r, "status": "tests_blind" if test_code else "unguarded", "test_code": test_code}
120
- return {**r, "status": "guarded", "caught": recent.caught_by(out["out"])}
121
+ caught = recent.caught_by(out["out"])
122
+ return {**r, "status": "guarded", "caught": caught, "hang": "" if caught else recent.hang_note(out, now)}
121
123
  finally:
122
124
  shutil.rmtree(folder.parent, ignore_errors=True)
123
125
 
@@ -134,6 +136,9 @@ def say(r: dict) -> str:
134
136
  return "Bite: no code your tests run has changed, so there's nothing to check."
135
137
  if s == "cant":
136
138
  return f"Bite couldn't check this change: {r['why']}."
139
+ if s == "guarded" and r.get("hang"):
140
+ return (f"Bite ✅ the tests noticed: with the change to {files(r['code'])} taken out, they hang "
141
+ f"({r['hang']}).")
137
142
  if s == "guarded":
138
143
  who = f" ({r['caught']})" if r.get("caught") else ""
139
144
  return f"Bite ✅ a test noticed: with the change to {files(r['code'])} taken out, the tests fail{who}."
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bite-check"
7
- version = "1.0.0"
7
+ version = "1.0.2"
8
8
  description = "Checks whether the \"fixed\" in your coding agent's recent commits is actually true."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -27,6 +27,7 @@ test = ["pytest", "pytest-xdist"]
27
27
 
28
28
  [project.scripts]
29
29
  bite = "recent:main"
30
+ bite-check = "recent:main" # so `uvx bite-check` and `pipx run bite-check` work with no --from
30
31
 
31
32
  [project.urls]
32
33
  Homepage = "https://github.com/connabyss/bite"
@@ -945,25 +945,43 @@ def tests_pass_at(root: Path, sha: str, cfg) -> tuple[bool, str]:
945
945
  JOBS = [1] # --jobs N: how many commits are checked at the same time
946
946
  _GATE = threading.Condition()
947
947
  _BUSY = [0, False] # test runs going on side by side, and whether one has the machine to itself
948
+ _SHARED = {} # each test run going on now: whether another ran beside it at any point
948
949
 
949
950
 
950
- def run_tests(fcfg, folder) -> dict:
951
+ def run_tests(fcfg, folder, alone: bool = False, confirm: bool = True) -> dict:
951
952
  """bite.run_tests, and with --jobs, side by side with other commits' runs. Tests run side by side can trip over
952
953
  each other (the same port, the same database), and a failure caused by that would read as "a test noticed" and
953
- hide a real gap. So with --jobs a failed run counts only if it fails again with the machine to itself."""
954
+ hide a real gap. So with --jobs a failed run that shared the machine with another run counts only if it fails
955
+ again with the machine to itself. A run that had the machine to itself anyway (one commit to check, or the
956
+ others already done) isn't run twice: that only doubled the time of every failing run.
957
+ alone: run it with the machine to itself straight away. confirm=False: return a failed shared run as it is
958
+ (marked "shared"), for a caller whose next run (alone) confirms it anyway."""
954
959
  if JOBS[0] <= 1:
955
960
  return bite.run_tests(fcfg, folder)
961
+ if alone:
962
+ return _run_alone(fcfg, folder)
963
+ me = object()
956
964
  with _GATE:
957
965
  _GATE.wait_for(lambda: not _BUSY[1])
958
966
  _BUSY[0] += 1
967
+ _SHARED[me] = bool(_SHARED)
968
+ for other in _SHARED: # every run going on now shares the machine with this one
969
+ _SHARED[other] = _SHARED[other] or _SHARED[me]
959
970
  try:
960
971
  r = bite.run_tests(fcfg, folder)
961
972
  finally:
962
973
  with _GATE:
963
974
  _BUSY[0] -= 1
975
+ shared = _SHARED.pop(me)
964
976
  _GATE.notify_all()
965
- if r["ok"]:
977
+ if r["ok"] or not shared:
966
978
  return r
979
+ if not confirm:
980
+ return {**r, "shared": True}
981
+ return _run_alone(fcfg, folder)
982
+
983
+
984
+ def _run_alone(fcfg, folder) -> dict:
967
985
  with _GATE:
968
986
  _GATE.wait_for(lambda: not _BUSY[1] and not _BUSY[0])
969
987
  _BUSY[1] = True
@@ -1019,6 +1037,11 @@ def remember(root: Path, memo: dict) -> None:
1019
1037
  (proof_dir(root) / "checked.json").write_text(json.dumps(memo, indent=1), encoding="utf-8")
1020
1038
 
1021
1039
 
1040
+ def doing(c: dict, text: str) -> None:
1041
+ """One line before each test run, so a slow check shows what it is doing instead of going quiet."""
1042
+ print(f" {c['sha'][:8]} {text}", flush=True)
1043
+
1044
+
1022
1045
  def undo_check(root: Path, c: dict, cfg) -> str:
1023
1046
  """check_undo, but a file Bite can't read or write in one commit's copy (a link, a locked file, a full disk)
1024
1047
  only stops the check of that commit, not the whole run: it shows as couldn't check, with the reason."""
@@ -1040,14 +1063,21 @@ def check_undo(root: Path, c: dict, cfg) -> str:
1040
1063
  if getattr(cfg, "free_cmd", ""): # the faster subset, for this step only
1041
1064
  fcfg.test_cmd, fcfg.box_test_cmd = cfg.free_cmd, ""
1042
1065
  try:
1066
+ doing(c, "running the tests with the change in...")
1043
1067
  now = run_tests(fcfg, folder)
1044
1068
  if not now["ok"]:
1045
1069
  c["why"] = fail_why(now)
1046
1070
  return "tests fail at this commit"
1047
1071
  if ran_nothing(bite.tests_cmd(fcfg), now["out"]):
1048
1072
  return "no tests ran at this commit"
1073
+ bite.cap_hang(fcfg, now)
1049
1074
  take_out(root, c, folder)
1050
- undone = run_tests(fcfg, folder)
1075
+ doing(c, f"running them with the change taken out (they took {took(now)} with it in)...")
1076
+ # A failure beside another commit's run (--jobs) is confirmed by the next run, alone: the rerun below, or
1077
+ # here when there is none. Confirming it straight away too ran every noticed change's tests twice more.
1078
+ undone = run_tests(fcfg, folder, confirm=False)
1079
+ if undone.get("shared") and (QUICK[0] or bite.load_failure(undone["out"])):
1080
+ undone = run_tests(fcfg, folder, alone=True)
1051
1081
 
1052
1082
  def flag(): # would be flagged: first rule out the usual harmless reasons
1053
1083
  if c.get("skip"):
@@ -1065,11 +1095,15 @@ def check_undo(root: Path, c: dict, cfg) -> str:
1065
1095
  if not crashed and not QUICK[0]:
1066
1096
  # Once more: a test that fails now and then (flaky) can fail by luck. Only a test that fails every
1067
1097
  # time the change is out counts as noticing it.
1068
- again = run_tests(fcfg, folder)
1098
+ doing(c, "a test failed: running them once more, to rule out a test that fails by luck"
1099
+ + (" (alone, so the other check running beside it can't be the cause)..." if undone.get("shared") else "..."))
1100
+ again = run_tests(fcfg, folder, alone=bool(undone.get("shared")))
1069
1101
  if again["ok"]:
1070
- c["flaky"] = caught_by(undone["out"]) or "a test"
1102
+ if not undone.get("shared"): # beside another run, it may have been the other run's doing
1103
+ c["flaky"] = caught_by(undone["out"]) or "a test"
1071
1104
  return flag()
1072
1105
  c["caught"] = caught_by(undone["out"])
1106
+ hung = not c["caught"] and hang_note(undone, now)
1073
1107
  missed = unnoticed_part(root, c, folder, fcfg)
1074
1108
  if missed and c.get("skip"):
1075
1109
  return f"not flagged: {c['skip']}"
@@ -1091,6 +1125,9 @@ def check_undo(root: Path, c: dict, cfg) -> str:
1091
1125
  c["caught"], c["gone"] = caught, gone
1092
1126
  return "guard gone since"
1093
1127
  c["caught"] = caught
1128
+ if hung:
1129
+ c["hang"] = hung
1130
+ return "guarded (tests hang without it)"
1094
1131
  return "guarded" if not crashed else "guarded (tests use the new code)"
1095
1132
  finally:
1096
1133
  shutil.rmtree(folder.parent, ignore_errors=True)
@@ -1229,7 +1266,7 @@ def unnoticed_part(root: Path, c: dict, folder: Path, fcfg) -> list:
1229
1266
  PART_RUNS[0] -= len(ps)
1230
1267
  news = {rel: repo.git(root, "cat-file", "blob", f"{c['sha']}:{rel}", raw=True) for rel in c["code"]}
1231
1268
  missed = []
1232
- for p in ps:
1269
+ for n, p in enumerate(ps, 1):
1233
1270
  rel, undone = p[0], p[2]
1234
1271
  for r, b in news.items(): # the whole change back in
1235
1272
  if b is not None:
@@ -1238,6 +1275,7 @@ def unnoticed_part(root: Path, c: dict, folder: Path, fcfg) -> list:
1238
1275
  take_out(root, {**c, "code": [rel]}, folder) # a file the commit created
1239
1276
  else:
1240
1277
  bite.safe_write(folder, folder / rel, undone)
1278
+ doing(c, f"piece {n} of {len(ps)} ({p[1] + ' in ' if p[1] else ''}{p[0]}) taken out alone: does a test notice it?")
1241
1279
  run = run_tests(fcfg, folder)
1242
1280
  if run["ok"] and not ran_nothing(bite.tests_cmd(fcfg), run["out"]):
1243
1281
  missed.append(p)
@@ -1259,6 +1297,18 @@ def caught_by(out: str) -> str:
1259
1297
  return f"`{names[0]}`" + (f" and {len(names) - 1} more" if len(names) > 1 else "")
1260
1298
 
1261
1299
 
1300
+ def took(run: dict) -> str:
1301
+ secs = round(run.get("secs") or 0)
1302
+ return f"{secs // 60}m {secs % 60}s" if secs >= 60 else f"{secs}s"
1303
+
1304
+
1305
+ def hang_note(undone: dict, now: dict) -> str:
1306
+ """When the tests hung with the change taken out (see bite.cap_hang), that is what noticed it, in words."""
1307
+ if not undone.get("hung"):
1308
+ return ""
1309
+ return f"stopped after {round(undone['secs'])}s; they take {round(now['secs'])}s with it"
1310
+
1311
+
1262
1312
  def closed_since(root: Path, c: dict, cfg) -> bool:
1263
1313
  """A later commit may have closed the gap: on today's code, take this commit's change back out (if it still
1264
1314
  applies) and see whether today's tests now fail where they passed before."""
@@ -1316,14 +1366,18 @@ def today_notices(root: Path, c: dict, cfg):
1316
1366
  diff, flags = change_diff(root, c, 0), ["--unidiff-zero"]
1317
1367
  if not diff or not git_apply_ok(folder, diff, "-R", "--check", *flags):
1318
1368
  return None # today's code moved on: can't tell
1369
+ doing(c, "on today's code: running the tests with the change in...")
1319
1370
  now = run_tests(fcfg, folder)
1320
1371
  if not now["ok"] or ran_nothing(bite.tests_cmd(fcfg), now["out"]):
1321
1372
  return None
1373
+ bite.cap_hang(fcfg, now)
1322
1374
  git_apply_ok(folder, diff, "-R", *flags)
1375
+ doing(c, "on today's code: running them with the change taken out...")
1323
1376
  undone = run_tests(fcfg, folder)
1324
1377
  if undone["ok"]:
1325
1378
  return False
1326
- c["caught"] = caught_by(undone["out"])
1379
+ hung = hang_note(undone, now)
1380
+ c["caught"] = caught_by(undone["out"]) or (f"the tests hang without it: {hung}" if hung else "")
1327
1381
  return None if bite.load_failure(undone["out"]) else True
1328
1382
  finally:
1329
1383
  shutil.rmtree(folder.parent, ignore_errors=True)
@@ -1750,6 +1804,9 @@ def say(c: dict, newest: str = "") -> tuple[str, str, str]:
1750
1804
  "today's code. **Next:** if that wasn't on purpose, put the fix back with a test that fails without it.")
1751
1805
  if u.startswith("closed since"):
1752
1806
  return "held", "✅ a later test notices", f"✅ A test added in a later commit notices it now{f' ({caught})' if caught else ''}."
1807
+ if u == "guarded (tests hang without it)":
1808
+ return ("held", "✅ tests hang without it", "✅ Without this change your tests hang"
1809
+ f"{f' ({h})' if (h := c.get('hang')) else ''}, so they'd notice if it went.")
1753
1810
  if u == "guarded (tests use the new code)":
1754
1811
  return "held", "✅ tests crash without it", "✅ Without this change your tests crash, so they'd notice if it went."
1755
1812
  if u.startswith("guarded"):
@@ -2519,7 +2576,31 @@ def _newer(latest, current):
2519
2576
  return False
2520
2577
 
2521
2578
 
2579
+ def leave_own_python() -> None:
2580
+ """Run the project's tests with the project's Python, not Bite's own. A tool runner like uvx
2581
+ (`uvx --from bite-check bite --free`) puts Bite's private environment first on PATH, so "python3 -m pytest"
2582
+ found a Python with no pytest and none of the project's packages. Drop that folder from PATH unless it's an
2583
+ environment the user turned on (VIRTUAL_ENV) or one inside the project (uv run, poetry, .venv)."""
2584
+ if sys.prefix == sys.base_prefix:
2585
+ return # Bite runs in a plain Python: nothing of its own to step around
2586
+ own = Path(sys.prefix, "Scripts" if bite.WINDOWS else "bin")
2587
+ parts = os.environ.get("PATH", "").split(os.pathsep)
2588
+ same = lambda a, b: os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b))
2589
+ if not parts or not parts[0] or not same(parts[0], own):
2590
+ return # pipx, pip install and the plugin leave PATH alone: their Python is found the usual way
2591
+ if os.environ.get("VIRTUAL_ENV") and same(os.environ["VIRTUAL_ENV"], sys.prefix):
2592
+ return
2593
+ try:
2594
+ Path.cwd().resolve().relative_to(Path(sys.prefix).resolve().parent)
2595
+ return # the project's own .venv, with Bite installed in it
2596
+ except ValueError:
2597
+ pass
2598
+ os.environ["PATH"] = os.pathsep.join(p for p in parts if not (p and same(p, own)))
2599
+
2600
+
2522
2601
  def main(argv=None):
2602
+ if argv is None: # run from the command line (a test calling main() keeps its own PATH)
2603
+ leave_own_python()
2523
2604
  argv = list(sys.argv[1:] if argv is None else argv)
2524
2605
  if "--json" in argv and argv[:1] not in (["live"], ["ask"], ["mcp"]) and not {"-h", "--help", "--version"} & set(argv):
2525
2606
  # Only the results go to stdout, so a program can read them; what Bite says along the way goes to stderr.
@@ -2624,6 +2705,7 @@ def main(argv=None):
2624
2705
  return 1
2625
2706
  t0 = time.time()
2626
2707
  cs = commits(root, opt("--last", 40), since) # --all is still accepted: every commit is the default now
2708
+ seen = len(cs)
2627
2709
  top = cs[0]["sha"] if cs else "" # the newest commit of all, even one that changed no code
2628
2710
  for c in cs:
2629
2711
  files_of(root, c, cfg)
@@ -2639,7 +2721,11 @@ def main(argv=None):
2639
2721
  + ", ".join(c["sha"][:8] for c in other), flush=True)
2640
2722
  if not cs:
2641
2723
  thin = repo.shallow(root) # --last can't help: the history isn't there
2642
- print(f"Nothing to check. {repo.SHALLOW}" if thin else "Nothing to check. Try a larger --last.")
2724
+ whole = not since and seen < int(opt("--last", 40)) # git had fewer commits than asked for
2725
+ print(f"Nothing to check. {repo.SHALLOW}" if thin else
2726
+ f"Nothing to check: that was every commit in this repository ({seen}), and none changed code your "
2727
+ f"tests run ({lang}). Make a change with a test, commit it, and run Bite again." if whole else
2728
+ "Nothing to check. Try a larger --last.")
2643
2729
  write_comment(root, [], none=f"The code changed here isn't {lang}, and your tests only run {lang}, so they "
2644
2730
  "can't notice it." if other else repo.SHALLOW if thin else "")
2645
2731
  return 0
@@ -2669,7 +2755,8 @@ def main(argv=None):
2669
2755
  cs = [c for c in cs if c["test_code"]]
2670
2756
  print(f"No fix claims found in the last {opt('--last', 40)} commits, so there's nothing to hold them to"
2671
2757
  + (f". Checking the {len(cs)} that added tests: do those tests check the change?" if cs else
2672
- ". (--any checks them anyway.)"), flush=True)
2758
+ ", and none of them added tests. To check every change anyway, run:\n "
2759
+ + " ".join(["bite", *argv, "--any"])), flush=True)
2673
2760
  if not cs:
2674
2761
  write_comment(root, [], none="No commit in this pull request says it fixes something, so there's no claim to check.")
2675
2762
  return 0
@@ -0,0 +1,88 @@
1
+ """A newcomer's first run on their own small repo, walked through by hand (2026-11-02) before Hack Week. Three dead
2
+ ends, each fixed here, and each test failed before its fix. No key, no network.
3
+
4
+ 1. `uvx --from bite-check bite --free` put Bite's own private Python first on PATH, so the tests ran with a Python
5
+ that has no pytest and none of the project's packages: "pytest isn't installed for this Python".
6
+ 2. A repo whose commits never say "fix" and never added tests ended with "(--any checks them anyway.)", and no
7
+ command to copy.
8
+ 3. A tiny repo whose few commits changed no code said "Try a larger --last", which can't help: Bite already saw
9
+ every commit there is."""
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import venv
16
+ from pathlib import Path
17
+
18
+ KIT = Path(__file__).resolve().parent.parent
19
+ PY = sys.executable
20
+
21
+
22
+ def git(root, *a):
23
+ return subprocess.run(["git", *a], cwd=root, check=True, capture_output=True, text=True).stdout
24
+
25
+
26
+ def commit(root, msg):
27
+ git(root, "add", "-A")
28
+ git(root, "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-q", "-m", msg)
29
+
30
+
31
+ def bite(root, *args, env=None, py=PY):
32
+ return subprocess.run([py, str(KIT / "recent.py"), *args], cwd=root, capture_output=True, text=True,
33
+ timeout=300, env=env).stdout
34
+
35
+
36
+ def shop(tmp_path, fix_msg="Fix total rounding"):
37
+ root = tmp_path / "shop"
38
+ (root / "tests").mkdir(parents=True)
39
+ git(root, "init", "-q")
40
+ (root / "shop.py").write_text("def total(xs):\n return sum(xs)\n")
41
+ (root / "tests" / "test_shop.py").write_text("from shop import total\n\n\ndef test_total():\n"
42
+ " assert total([1, 2]) == 3\n")
43
+ commit(root, "start")
44
+ (root / "shop.py").write_text("def total(xs):\n return round(sum(xs), 2)\n")
45
+ commit(root, fix_msg)
46
+ return root
47
+
48
+
49
+ def test_run_through_uvx_uses_the_projects_python_not_bites_own(tmp_path):
50
+ root = shop(tmp_path)
51
+ own = tmp_path / "uv-cache" / "bite-env" # stands in for uvx's private environment, which has no pytest
52
+ venv.create(own, with_pip=False)
53
+ bin_dir = own / ("Scripts" if os.name == "nt" else "bin")
54
+ own_py = next(p for p in (bin_dir / "python.exe", bin_dir / "python3", bin_dir / "python") if p.exists())
55
+ env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONHOME", "PYTHONPATH")}
56
+ # uvx's PATH: its own bin first, then the user's (here: the Python that has pytest, as the project's would)
57
+ env["PATH"] = os.pathsep.join([str(bin_dir), str(Path(PY).parent), env.get("PATH", "")])
58
+ assert shutil.which("python", path=env["PATH"]) or shutil.which("python3", path=env["PATH"])
59
+ out = bite(root, "--free", env=env, py=str(own_py))
60
+ assert "isn't installed for this Python" not in out, out
61
+ assert "noticed" in out.lower(), out # the check actually ran the tests
62
+
63
+
64
+ def test_no_claims_and_no_new_tests_gives_the_command_to_check_anyway(tmp_path):
65
+ root = shop(tmp_path, fix_msg="round the total")
66
+ out = bite(root, "--free")
67
+ assert "bite --free --any" in out, out
68
+
69
+
70
+ def test_a_tiny_repo_with_no_code_changes_doesnt_say_try_a_larger_last(tmp_path):
71
+ root = tmp_path / "notes"
72
+ (root / "tests").mkdir(parents=True)
73
+ git(root, "init", "-q")
74
+ (root / "notes.py").write_text("X = 1\n")
75
+ (root / "tests" / "test_notes.py").write_text("def test_x():\n assert True\n")
76
+ (root / "README.md").write_text("# notes\n")
77
+ commit(root, "start")
78
+ (root / "README.md").write_text("# my notes\n")
79
+ commit(root, "Fix the title")
80
+ out = bite(root, "--free")
81
+ assert "Try a larger --last" not in out and "every commit in this repository" in out, out
82
+
83
+
84
+ def test_the_package_name_is_also_a_command_so_uvx_bite_check_works():
85
+ # `uvx bite-check --free` and `pipx run bite-check --free` look for a command named after the package.
86
+ import re
87
+ scripts = (KIT / "pyproject.toml").read_text(encoding="utf-8").split("[project.scripts]", 1)[1].split("\n[", 1)[0]
88
+ assert re.search(r'^bite-check = "recent:main"', scripts, re.M), scripts
@@ -0,0 +1,90 @@
1
+ """A fix that bounds a loop: with it taken out, the tests hang. That is the tests noticing, and Bite should say so
2
+ in about a minute, not wait out test_timeout (10 minutes) for every run of the check. Found on Scrapling, whose
3
+ "bound Cloudflare solver loops" fix stalled a free check for 25 minutes."""
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
14
+ import bite # noqa: E402
15
+ import live # noqa: E402
16
+ import recent # noqa: E402
17
+
18
+ HERE = str(Path(recent.__file__).resolve().parent)
19
+ GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"]
20
+
21
+ LOOP = '''def first_ready(flags):
22
+ i = 0
23
+ while not flags[i % len(flags)]:
24
+ i += 1
25
+ return i % len(flags)
26
+ '''
27
+ BOUNDED = '''def first_ready(flags):
28
+ for i, ok in enumerate(flags):
29
+ if ok:
30
+ return i
31
+ return None
32
+ '''
33
+ TEST = ("from waiter import first_ready\n\n\ndef test_nothing_ready():\n assert first_ready([False, False]) is None\n\n\n"
34
+ "def test_second_ready():\n assert first_ready([False, True]) == 1\n")
35
+ # Without the fix the hung run waits out this limit, so the time check below fails in two minutes, not ten.
36
+ SETTINGS = "[bite]\ntest_command = python -m pytest -q -p no:cacheprovider\ntest_timeout_seconds = 120\n"
37
+
38
+
39
+ def project() -> Path:
40
+ root = Path(tempfile.mkdtemp(prefix="bite_hang_"))
41
+ subprocess.run(["git", "init", "-q"], cwd=root, check=True)
42
+ steps = [("Start", {"waiter.py": LOOP, ".bite.ini": SETTINGS}),
43
+ ("Fix first_ready looping forever when nothing is ready", {"waiter.py": BOUNDED, "test_waiter.py": TEST})]
44
+ for subject, files in steps:
45
+ for name, text in files.items():
46
+ (root / name).write_text(text, encoding="utf-8", newline="\n")
47
+ subprocess.run([*GIT, "add", "-A"], cwd=root, check=True)
48
+ subprocess.run([*GIT, "commit", "-q", "-m", subject], cwd=root, check=True)
49
+ return root
50
+
51
+
52
+ # A floor of 3 seconds instead of 60, so the test is quick; the rule (5 times the passing run) is the real one.
53
+ RUN = ("import sys; sys.path.insert(0, sys.argv[1]); import bite; bite.HANG_FLOOR = 3; import recent; "
54
+ "sys.exit(recent.main(['--free', '--any', '--quick', '--json']))")
55
+
56
+
57
+ def test_a_hang_with_the_fix_taken_out_counts_as_noticed_and_stops_early():
58
+ root = project()
59
+ start = time.monotonic()
60
+ p = subprocess.run([sys.executable, "-c", RUN, HERE], cwd=root, capture_output=True, text=True,
61
+ encoding="utf-8", env={**os.environ, "PYTHONIOENCODING": "utf-8"})
62
+ took = time.monotonic() - start
63
+ assert p.stdout.strip(), p.stderr
64
+ r = next(c for c in json.loads(p.stdout)["commits"] if c["subject"].startswith("Fix first_ready"))
65
+ assert r["result"] == "noticed", r
66
+ assert "your tests hang" in r["detail"], r
67
+ assert took < 90, f"the hung run was not stopped early: {took:.0f}s"
68
+
69
+
70
+ def test_the_cap_follows_the_passing_run():
71
+ cfg = type("Cfg", (), {"test_timeout": 600})()
72
+ assert bite.run_limit(cfg) == 600
73
+ bite.cap_hang(cfg, {"secs": 2.0})
74
+ assert bite.run_limit(cfg) == bite.HANG_FLOOR # a quick suite: the floor
75
+ bite.cap_hang(cfg, {"secs": 40.0})
76
+ assert bite.run_limit(cfg) == 201
77
+ bite.cap_hang(cfg, {"secs": 500.0})
78
+ assert bite.run_limit(cfg) == 600 # never longer than test_timeout
79
+
80
+
81
+ def test_the_live_check_stops_a_hang_early_too(monkeypatch):
82
+ root = project()
83
+ monkeypatch.setattr(bite, "HANG_FLOOR", 3)
84
+ subprocess.run([*GIT, "reset", "-q", "--soft", "HEAD~1"], cwd=root, check=True) # the fix, not committed yet
85
+ start = time.monotonic()
86
+ r = live.check(root)
87
+ assert r["status"] == "guarded", r
88
+ assert r["hang"].startswith("stopped after"), r
89
+ assert "they hang" in live.say(r)
90
+ assert time.monotonic() - start < 90
@@ -0,0 +1,81 @@
1
+ """The free check of one commit took over 8 minutes on Bite's own pull request check, with the log quiet after "free
2
+ check of 1". Two fixes: with --jobs, a failing test run that had the machine to itself anyway isn't run a second
3
+ time, and each test run gets a line in the log saying what it is."""
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
14
+ import recent # noqa: E402
15
+
16
+ RECENT = str(Path(recent.__file__))
17
+ GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"]
18
+
19
+
20
+ def fake_runs(monkeypatch, secs=0.0):
21
+ calls = []
22
+
23
+ def run(fcfg, folder):
24
+ calls.append(folder)
25
+ time.sleep(secs)
26
+ return {"ok": False, "out": "", "why": "a test failed"}
27
+ monkeypatch.setattr(recent.bite, "run_tests", run)
28
+ monkeypatch.setattr(recent, "JOBS", [2])
29
+ return calls
30
+
31
+
32
+ def test_a_failing_run_alone_on_the_machine_is_not_run_twice(monkeypatch):
33
+ calls = fake_runs(monkeypatch)
34
+ assert not recent.run_tests(None, "a")["ok"]
35
+ assert calls == ["a"]
36
+
37
+
38
+ def test_a_failing_run_beside_another_is_run_again_alone(monkeypatch):
39
+ calls = fake_runs(monkeypatch, secs=0.3)
40
+ ts = [threading.Thread(target=recent.run_tests, args=(None, name)) for name in ("a", "b")]
41
+ for t in ts:
42
+ t.start()
43
+ for t in ts:
44
+ t.join()
45
+ assert sorted(calls) == ["a", "a", "b", "b"]
46
+
47
+
48
+ def test_a_failing_run_beside_another_can_leave_the_confirming_to_the_next_run(monkeypatch):
49
+ calls = fake_runs(monkeypatch, secs=0.3)
50
+ out = {}
51
+ ts = [threading.Thread(target=lambda n=name: out.update({n: recent.run_tests(None, n, confirm=False)}))
52
+ for name in ("a", "b")]
53
+ for t in ts:
54
+ t.start()
55
+ for t in ts:
56
+ t.join()
57
+ assert sorted(calls) == ["a", "b"] # not run again here: the flaky rerun, alone, confirms it
58
+ assert out["a"]["shared"] and out["b"]["shared"]
59
+ assert not recent.run_tests(None, "c", alone=True)["ok"]
60
+ assert sorted(calls) == ["a", "b", "c"]
61
+
62
+
63
+ def test_the_log_says_what_each_test_run_is():
64
+ root = Path(tempfile.mkdtemp(prefix="bite_progress_"))
65
+ subprocess.run(["git", "init", "-q"], cwd=root, check=True)
66
+ steps = [("Start", {"price.py": "def total(a, b):\n return a + b + 1\n",
67
+ "test_price.py": "from price import total\n\n\ndef test_ok():\n assert total(1, 1) >= 2\n"}),
68
+ ("Fix total adding one too many", {"price.py": "def total(a, b):\n return a + b\n",
69
+ "test_price.py": "from price import total\n\n\n"
70
+ "def test_ok():\n assert total(1, 1) == 2\n"})]
71
+ for subject, files in steps:
72
+ for name, text in files.items():
73
+ (root / name).write_text(text, encoding="utf-8", newline="\n")
74
+ subprocess.run([*GIT, "add", "-A"], cwd=root, check=True)
75
+ subprocess.run([*GIT, "commit", "-q", "-m", subject], cwd=root, check=True)
76
+ p = subprocess.run([sys.executable, RECENT, "--free", "--any"], cwd=root, capture_output=True, text=True,
77
+ encoding="utf-8", env={**os.environ, "PYTHONIOENCODING": "utf-8"})
78
+ log = p.stdout + p.stderr
79
+ assert "running the tests with the change in..." in log, log
80
+ assert "with the change taken out" in log, log
81
+ assert "once more, to rule out a test that fails by luck" in log, log
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes