blot-git 0.3.0__tar.gz → 0.3.1__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 (87) hide show
  1. {blot_git-0.3.0 → blot_git-0.3.1}/PKG-INFO +1 -1
  2. {blot_git-0.3.0 → blot_git-0.3.1}/pyproject.toml +1 -1
  3. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/__init__.py +1 -1
  4. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/index.py +37 -6
  5. {blot_git-0.3.0 → blot_git-0.3.1}/tests/test_index.py +14 -0
  6. {blot_git-0.3.0 → blot_git-0.3.1}/.gitattributes +0 -0
  7. {blot_git-0.3.0 → blot_git-0.3.1}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
  8. {blot_git-0.3.0 → blot_git-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  9. {blot_git-0.3.0 → blot_git-0.3.1}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
  10. {blot_git-0.3.0 → blot_git-0.3.1}/.github/pull_request_template.md +0 -0
  11. {blot_git-0.3.0 → blot_git-0.3.1}/.github/workflows/ci.yml +0 -0
  12. {blot_git-0.3.0 → blot_git-0.3.1}/.github/workflows/release.yml +0 -0
  13. {blot_git-0.3.0 → blot_git-0.3.1}/.gitignore +0 -0
  14. {blot_git-0.3.0 → blot_git-0.3.1}/.pre-commit-hooks.yaml +0 -0
  15. {blot_git-0.3.0 → blot_git-0.3.1}/CONTRIBUTING.md +0 -0
  16. {blot_git-0.3.0 → blot_git-0.3.1}/LICENSE +0 -0
  17. {blot_git-0.3.0 → blot_git-0.3.1}/README.md +0 -0
  18. {blot_git-0.3.0 → blot_git-0.3.1}/assets/demo/demo.gif +0 -0
  19. {blot_git-0.3.0 → blot_git-0.3.1}/assets/demo/make_demo.py +0 -0
  20. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/blue/alert.svg +0 -0
  21. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/blue/faces.svg +0 -0
  22. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/blue/happy.svg +0 -0
  23. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/blue/idle.svg +0 -0
  24. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/blue/sad.svg +0 -0
  25. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/blue/sleepy.svg +0 -0
  26. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/blue/walk.svg +0 -0
  27. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/blue/worried.svg +0 -0
  28. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/build_sprites.py +0 -0
  29. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/mustard/alert.svg +0 -0
  30. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/mustard/faces.svg +0 -0
  31. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/mustard/happy.svg +0 -0
  32. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/mustard/idle.svg +0 -0
  33. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/mustard/sad.svg +0 -0
  34. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/mustard/sleepy.svg +0 -0
  35. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/mustard/walk.svg +0 -0
  36. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/mustard/worried.svg +0 -0
  37. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/orange/alert.svg +0 -0
  38. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/orange/faces.svg +0 -0
  39. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/orange/happy.svg +0 -0
  40. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/orange/idle.svg +0 -0
  41. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/orange/sad.svg +0 -0
  42. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/orange/sleepy.svg +0 -0
  43. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/orange/walk.svg +0 -0
  44. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/orange/worried.svg +0 -0
  45. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/sage/alert.svg +0 -0
  46. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/sage/faces.svg +0 -0
  47. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/sage/happy.svg +0 -0
  48. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/sage/idle.svg +0 -0
  49. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/sage/sad.svg +0 -0
  50. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/sage/sleepy.svg +0 -0
  51. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/sage/walk.svg +0 -0
  52. {blot_git-0.3.0 → blot_git-0.3.1}/assets/sora/sage/worried.svg +0 -0
  53. {blot_git-0.3.0 → blot_git-0.3.1}/bench/README.md +0 -0
  54. {blot_git-0.3.0 → blot_git-0.3.1}/bench/RESULTS.md +0 -0
  55. {blot_git-0.3.0 → blot_git-0.3.1}/bench/cases.json +0 -0
  56. {blot_git-0.3.0 → blot_git-0.3.1}/docs/EXPLAINER.md +0 -0
  57. {blot_git-0.3.0 → blot_git-0.3.1}/docs/PRD.md +0 -0
  58. {blot_git-0.3.0 → blot_git-0.3.1}/docs/PYPI.md +0 -0
  59. {blot_git-0.3.0 → blot_git-0.3.1}/docs/competitors.md +0 -0
  60. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/__main__.py +0 -0
  61. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/bench.py +0 -0
  62. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/checks/__init__.py +0 -0
  63. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/checks/base.py +0 -0
  64. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/checks/cochange.py +0 -0
  65. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/checks/readded_bug.py +0 -0
  66. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/checks/removed_fix.py +0 -0
  67. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/classify.py +0 -0
  68. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/cli.py +0 -0
  69. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/config.py +0 -0
  70. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/diff.py +0 -0
  71. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/git.py +0 -0
  72. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/github.py +0 -0
  73. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/hooks.py +0 -0
  74. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/report.py +0 -0
  75. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/store.py +0 -0
  76. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/trace.py +0 -0
  77. {blot_git-0.3.0 → blot_git-0.3.1}/src/blot/why.py +0 -0
  78. {blot_git-0.3.0 → blot_git-0.3.1}/tests/conftest.py +0 -0
  79. {blot_git-0.3.0 → blot_git-0.3.1}/tests/test_bench.py +0 -0
  80. {blot_git-0.3.0 → blot_git-0.3.1}/tests/test_checks.py +0 -0
  81. {blot_git-0.3.0 → blot_git-0.3.1}/tests/test_classify.py +0 -0
  82. {blot_git-0.3.0 → blot_git-0.3.1}/tests/test_cli.py +0 -0
  83. {blot_git-0.3.0 → blot_git-0.3.1}/tests/test_config.py +0 -0
  84. {blot_git-0.3.0 → blot_git-0.3.1}/tests/test_diff.py +0 -0
  85. {blot_git-0.3.0 → blot_git-0.3.1}/tests/test_git.py +0 -0
  86. {blot_git-0.3.0 → blot_git-0.3.1}/tests/test_hooks.py +0 -0
  87. {blot_git-0.3.0 → blot_git-0.3.1}/tests/test_why_github.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: blot-git
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A pre-commit check that has read your project's history: warns when a change removes a fix, re-adds a fixed bug, or forgets a file that always changes too.
5
5
  Project-URL: Homepage, https://github.com/Sym-jay/Blot
6
6
  Project-URL: Issues, https://github.com/Sym-jay/Blot/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "blot-git"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "A pre-commit check that has read your project's history: warns when a change removes a fix, re-adds a fixed bug, or forgets a file that always changes too."
5
5
  readme = "docs/PYPI.md" # text-only; the GitHub README's relative Sora images break on PyPI
6
6
  license = { text = "MIT" }
@@ -1,3 +1,3 @@
1
1
  """blot: history-aware pre-commit checks."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -10,9 +10,11 @@ Targets: under 60 s for 50k commits on a laptop; incremental refresh under 1 s.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import os
13
14
  import re
14
15
  import subprocess
15
16
  import time
17
+ from concurrent.futures import ThreadPoolExecutor
16
18
  from dataclasses import dataclass
17
19
 
18
20
  from blot.classify import CommitInfo, fix_severity, is_refactor, linked_issues
@@ -85,12 +87,43 @@ def _head(repo: str, rev: str = "HEAD") -> str | None:
85
87
  return None
86
88
 
87
89
 
90
+ _FORMAT = f"--format={_RECORD}%H{_FIELD}%aI{_FIELD}%s{_FIELD}%b{_FIELD}"
91
+ # Most of indexing is git computing --numstat line counts, one commit after another. Big histories
92
+ # are split into slices that separate git processes read in parallel (Django: 34k commits, ~170 s
93
+ # serially). The result is the same set of commits; order doesn't matter to the index.
94
+ _PARALLEL_FROM = 2000
95
+
96
+
97
+ def _workers() -> int:
98
+ return max(1, min(8, (os.cpu_count() or 2) - 1))
99
+
100
+
101
+ def _git_stdin(repo: str, args: list[str], revs: list[str]) -> str:
102
+ done = subprocess.run(["git", "-C", repo, *args], input="\n".join(revs) + "\n", capture_output=True,
103
+ text=True, encoding="utf-8", errors="replace", check=True)
104
+ return done.stdout
105
+
106
+
107
+ def _read_history(repo: str, rev: str) -> list[tuple[CommitInfo, str, list[str]]]:
108
+ args = ["log", "--no-walk=unsorted", "--stdin", "-M", "--numstat", _FORMAT]
109
+ shas = run_git(repo, "rev-list", "--no-merges", rev).split()
110
+ if len(shas) < _PARALLEL_FROM:
111
+ return _parse_log(_git_stdin(repo, args, shas)) if shas else []
112
+ # interleaved slices have similar sizes; never pass an empty one (git log would fall back to HEAD)
113
+ slices = [part for i in range(_workers() * 2) if (part := shas[i::_workers() * 2])]
114
+ with ThreadPoolExecutor(_workers()) as pool:
115
+ outs = pool.map(lambda part: _git_stdin(repo, args, part), slices)
116
+ return [commit for out in outs for commit in _parse_log(out)]
117
+
118
+
88
119
  def _removed_lines(repo: str, shas: list[str]) -> list[tuple[str, str, str]]:
89
120
  """(line_hash, path, sha) for every meaningful line the given commits deleted."""
121
+ batches = [shas[i : i + _SHOW_BATCH] for i in range(0, len(shas), _SHOW_BATCH)]
122
+ show = ["show", f"--format={_RECORD}%H", "-U0", "--no-color", "--no-ext-diff", "-M"]
123
+ with ThreadPoolExecutor(_workers()) as pool:
124
+ outs = list(pool.map(lambda batch: run_git(repo, *show, *batch), batches))
90
125
  rows: list[tuple[str, str, str]] = []
91
- for i in range(0, len(shas), _SHOW_BATCH):
92
- batch = shas[i : i + _SHOW_BATCH]
93
- out = run_git(repo, "show", f"--format={_RECORD}%H", "-U0", "--no-color", "--no-ext-diff", "-M", *batch)
126
+ for out in outs:
94
127
  for chunk in out.split(_RECORD)[1:]:
95
128
  sha, _, diff_text = chunk.partition("\n")
96
129
  for change in parse_unified_diff(diff_text):
@@ -128,9 +161,7 @@ def build_index(
128
161
  return IndexResult(0, 0, time.perf_counter() - start, rebuild)
129
162
 
130
163
  rev = head if rebuild else f"{last}..{head}"
131
- fmt = f"--format={_RECORD}%H{_FIELD}%aI{_FIELD}%s{_FIELD}%b{_FIELD}"
132
- log = run_git(repo, "log", "--no-merges", "-M", "--numstat", fmt, rev)
133
- parsed = _parse_log(log)
164
+ parsed = _read_history(repo, rev)
134
165
 
135
166
  fix_shas = []
136
167
  for info, date, paths in parsed:
@@ -66,6 +66,20 @@ def test_index_db_lives_in_git_dir(make_repo):
66
66
  assert os.path.exists(repo / ".git" / "blot.db")
67
67
 
68
68
 
69
+ def test_parallel_history_read_matches_serial(make_repo, tmp_path, monkeypatch):
70
+ import blot.index as ix
71
+
72
+ repo = make_repo(history())
73
+ build_index(str(repo), db_path=str(tmp_path / "s.db"))
74
+ monkeypatch.setattr(ix, "_PARALLEL_FROM", 1) # force slicing even for 3 commits
75
+ monkeypatch.setattr(ix, "_workers", lambda: 3) # 6 slices for 3 commits: some would be empty
76
+ build_index(str(repo), db_path=str(tmp_path / "p.db"))
77
+ a, b = Store.open(str(repo), str(tmp_path / "s.db")), Store.open(str(repo), str(tmp_path / "p.db"))
78
+ for table in ("commits", "fix_removed_lines", "file_commits", "cochange"):
79
+ rows = lambda s: sorted(s.conn.execute(f"SELECT * FROM {table}").fetchall()) # noqa: E731
80
+ assert rows(a) == rows(b), table
81
+
82
+
69
83
  def test_empty_repo(tmp_path):
70
84
  subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
71
85
  assert build_index(str(tmp_path)).commits == 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes