blot-git 0.3.0__tar.gz → 0.3.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 (88) hide show
  1. {blot_git-0.3.0 → blot_git-0.3.2}/.pre-commit-hooks.yaml +4 -1
  2. {blot_git-0.3.0 → blot_git-0.3.2}/PKG-INFO +2 -2
  3. {blot_git-0.3.0 → blot_git-0.3.2}/README.md +1 -1
  4. {blot_git-0.3.0 → blot_git-0.3.2}/docs/PYPI.md +1 -1
  5. {blot_git-0.3.0 → blot_git-0.3.2}/pyproject.toml +1 -1
  6. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/__init__.py +1 -1
  7. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/cli.py +20 -12
  8. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/git.py +40 -0
  9. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/index.py +42 -9
  10. {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_cli.py +18 -2
  11. blot_git-0.3.2/tests/test_git.py +36 -0
  12. {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_hooks.py +1 -1
  13. {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_index.py +14 -0
  14. {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_why_github.py +2 -3
  15. blot_git-0.3.0/tests/test_git.py +0 -18
  16. {blot_git-0.3.0 → blot_git-0.3.2}/.gitattributes +0 -0
  17. {blot_git-0.3.0 → blot_git-0.3.2}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
  18. {blot_git-0.3.0 → blot_git-0.3.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  19. {blot_git-0.3.0 → blot_git-0.3.2}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
  20. {blot_git-0.3.0 → blot_git-0.3.2}/.github/pull_request_template.md +0 -0
  21. {blot_git-0.3.0 → blot_git-0.3.2}/.github/workflows/ci.yml +0 -0
  22. {blot_git-0.3.0 → blot_git-0.3.2}/.github/workflows/release.yml +0 -0
  23. {blot_git-0.3.0 → blot_git-0.3.2}/.gitignore +0 -0
  24. {blot_git-0.3.0 → blot_git-0.3.2}/CONTRIBUTING.md +0 -0
  25. {blot_git-0.3.0 → blot_git-0.3.2}/LICENSE +0 -0
  26. {blot_git-0.3.0 → blot_git-0.3.2}/assets/demo/demo.gif +0 -0
  27. {blot_git-0.3.0 → blot_git-0.3.2}/assets/demo/make_demo.py +0 -0
  28. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/alert.svg +0 -0
  29. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/faces.svg +0 -0
  30. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/happy.svg +0 -0
  31. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/idle.svg +0 -0
  32. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/sad.svg +0 -0
  33. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/sleepy.svg +0 -0
  34. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/walk.svg +0 -0
  35. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/worried.svg +0 -0
  36. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/build_sprites.py +0 -0
  37. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/alert.svg +0 -0
  38. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/faces.svg +0 -0
  39. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/happy.svg +0 -0
  40. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/idle.svg +0 -0
  41. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/sad.svg +0 -0
  42. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/sleepy.svg +0 -0
  43. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/walk.svg +0 -0
  44. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/worried.svg +0 -0
  45. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/alert.svg +0 -0
  46. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/faces.svg +0 -0
  47. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/happy.svg +0 -0
  48. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/idle.svg +0 -0
  49. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/sad.svg +0 -0
  50. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/sleepy.svg +0 -0
  51. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/walk.svg +0 -0
  52. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/worried.svg +0 -0
  53. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/alert.svg +0 -0
  54. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/faces.svg +0 -0
  55. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/happy.svg +0 -0
  56. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/idle.svg +0 -0
  57. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/sad.svg +0 -0
  58. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/sleepy.svg +0 -0
  59. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/walk.svg +0 -0
  60. {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/worried.svg +0 -0
  61. {blot_git-0.3.0 → blot_git-0.3.2}/bench/README.md +0 -0
  62. {blot_git-0.3.0 → blot_git-0.3.2}/bench/RESULTS.md +0 -0
  63. {blot_git-0.3.0 → blot_git-0.3.2}/bench/cases.json +0 -0
  64. {blot_git-0.3.0 → blot_git-0.3.2}/docs/EXPLAINER.md +0 -0
  65. {blot_git-0.3.0 → blot_git-0.3.2}/docs/PRD.md +0 -0
  66. {blot_git-0.3.0 → blot_git-0.3.2}/docs/competitors.md +0 -0
  67. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/__main__.py +0 -0
  68. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/bench.py +0 -0
  69. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/checks/__init__.py +0 -0
  70. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/checks/base.py +0 -0
  71. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/checks/cochange.py +0 -0
  72. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/checks/readded_bug.py +0 -0
  73. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/checks/removed_fix.py +0 -0
  74. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/classify.py +0 -0
  75. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/config.py +0 -0
  76. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/diff.py +0 -0
  77. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/github.py +0 -0
  78. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/hooks.py +0 -0
  79. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/report.py +0 -0
  80. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/store.py +0 -0
  81. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/trace.py +0 -0
  82. {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/why.py +0 -0
  83. {blot_git-0.3.0 → blot_git-0.3.2}/tests/conftest.py +0 -0
  84. {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_bench.py +0 -0
  85. {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_checks.py +0 -0
  86. {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_classify.py +0 -0
  87. {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_config.py +0 -0
  88. {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_diff.py +0 -0
@@ -1,11 +1,14 @@
1
1
  # Lets users of the pre-commit framework add blot with:
2
2
  # - repo: https://github.com/Sym-jay/Blot
3
- # rev: v0.1.0
3
+ # rev: v0.3.2
4
4
  # hooks: [{id: blot}]
5
+ # The first commit indexes the repo's history; later commits refresh the index incrementally.
5
6
  - id: blot
6
7
  name: blot (history-aware checks)
7
8
  entry: blot check --staged
8
9
  language: python
9
10
  pass_filenames: false
10
11
  always_run: true
12
+ # blot warns without failing the commit, and pre-commit hides output of passing hooks
13
+ verbose: true
11
14
  stages: [pre-commit]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: blot-git
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -40,7 +40,7 @@ It runs locally, needs no account or AI, and stays quiet unless the history give
40
40
  ```bash
41
41
  pip install blot-git
42
42
  cd your-repo
43
- blot index # one-time, builds .git/blot.db
43
+ blot index # optional: the first check indexes history itself
44
44
  blot install-hook # adds Blot to your pre-commit hook
45
45
  blot why src/app.py:42 # which commit explains this line, skipping refactors
46
46
  blot index --github # optional: issue/PR titles and labels via the gh CLI
@@ -49,7 +49,7 @@ Sora comes in four warm palettes:
49
49
  ```bash
50
50
  pip install blot-git # the PyPI name is blot-git; the command is just `blot`
51
51
  cd your-repo
52
- blot index # one-time, builds .git/blot.db (refreshes incrementally after that)
52
+ blot index # optional: the first check builds .git/blot.db itself and keeps it current
53
53
  blot install-hook # adds Blot to .git/hooks/pre-commit without touching existing hooks
54
54
  ```
55
55
 
@@ -15,7 +15,7 @@ It runs locally, needs no account or AI, and stays quiet unless the history give
15
15
  ```bash
16
16
  pip install blot-git
17
17
  cd your-repo
18
- blot index # one-time, builds .git/blot.db
18
+ blot index # optional: the first check indexes history itself
19
19
  blot install-hook # adds Blot to your pre-commit hook
20
20
  blot why src/app.py:42 # which commit explains this line, skipping refactors
21
21
  blot index --github # optional: issue/PR titles and labels via the gh CLI
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "blot-git"
3
- version = "0.3.0"
3
+ version = "0.3.2"
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.2"
@@ -11,6 +11,7 @@ Commands:
11
11
  from __future__ import annotations
12
12
 
13
13
  import argparse
14
+ import os
14
15
  import sys
15
16
 
16
17
  from blot import __version__
@@ -42,17 +43,28 @@ def _cmd_index(args: argparse.Namespace) -> int:
42
43
  return 0
43
44
 
44
45
 
46
+ def _open_index(repo: str):
47
+ """Open the index, building or refreshing it first.
48
+
49
+ The pre-commit framework installs Blot where users can't easily run `blot index`, so the hook
50
+ must look after the index itself: one full build on first use, then cheap incremental refreshes.
51
+ """
52
+ from blot.index import build_index
53
+ from blot.store import Store
54
+
55
+ if not os.path.exists(Store.db_path(repo)):
56
+ print("blot: first run, indexing this repo's history once (big repos can take a minute)...",
57
+ file=sys.stderr)
58
+ build_index(repo)
59
+ return Store.open(repo)
60
+
61
+
45
62
  def _cmd_check(args: argparse.Namespace) -> int:
46
63
  from blot.diff import read_changes
47
- from blot.store import Store
48
64
 
49
65
  try:
50
66
  config = load_config(args.repo)
51
- store = Store.open(args.repo)
52
- if store is None:
53
- # Never break a commit because the index is missing; just say how to fix it.
54
- print("blot: no index yet, run `blot index` (commit allowed)", file=sys.stderr)
55
- return 0
67
+ store = _open_index(args.repo)
56
68
  changes = read_changes(args.repo, rev_range=args.range)
57
69
  findings = [f for check in all_checks(config) for f in check.run(changes, store, config)]
58
70
  except Exception as exc: # Blot's own failures must never block a commit
@@ -63,13 +75,9 @@ def _cmd_check(args: argparse.Namespace) -> int:
63
75
 
64
76
 
65
77
  def _cmd_why(args: argparse.Namespace) -> int:
66
- from blot.store import Store
67
78
  from blot.why import WhyError, explain, render
68
79
 
69
- store = Store.open(args.repo)
70
- if store is None:
71
- print("blot: no index yet, run `blot index` first", file=sys.stderr)
72
- return 1
80
+ store = _open_index(args.repo)
73
81
  try:
74
82
  path, explained = explain(store, args.location, args.rev)
75
83
  except WhyError as exc:
@@ -90,7 +98,7 @@ def _cmd_install_hook(args: argparse.Namespace) -> int:
90
98
  status = hooks.install(args.repo)
91
99
  print(f"blot: pre-commit hook {status} ({hooks.hook_path(args.repo)})")
92
100
  if status != "already installed":
93
- print("blot: run `blot index` once so the checks have history to work with")
101
+ print("blot: the first commit indexes this repo's history (or run `blot index` now)")
94
102
  return 0
95
103
 
96
104
 
@@ -34,6 +34,46 @@ def git_dir(repo: str) -> str:
34
34
  return run_git(repo, "rev-parse", "--absolute-git-dir").strip()
35
35
 
36
36
 
37
+ def head_sha(repo: str) -> str | None:
38
+ """The commit HEAD points at, read from .git files (the hook calls this on every commit).
39
+
40
+ Handles loose and packed refs, detached HEAD and worktrees (`commondir`). Anything else, such as
41
+ the reftable format, falls back to `git rev-parse`.
42
+ """
43
+ gd = git_dir(repo)
44
+ try:
45
+ with open(os.path.join(gd, "HEAD"), encoding="utf-8") as f:
46
+ head = f.read().strip()
47
+ if not head.startswith("ref: "):
48
+ return head if len(head) == 40 else None
49
+ ref = head[5:]
50
+ bases = [gd]
51
+ common = os.path.join(gd, "commondir")
52
+ if os.path.isfile(common):
53
+ with open(common, encoding="utf-8") as f:
54
+ bases.append(os.path.normpath(os.path.join(gd, f.read().strip())))
55
+ for base in bases:
56
+ loose = os.path.join(base, *ref.split("/"))
57
+ if os.path.isfile(loose):
58
+ with open(loose, encoding="utf-8") as f:
59
+ return f.read().strip()
60
+ packed = os.path.join(base, "packed-refs")
61
+ if os.path.isfile(packed):
62
+ with open(packed, encoding="utf-8") as f:
63
+ for line in f:
64
+ sha, _, name = line.strip().partition(" ")
65
+ if name == ref:
66
+ return sha
67
+ if not os.path.isdir(os.path.join(gd, "reftable")):
68
+ return None # unborn branch: no commits yet
69
+ except OSError:
70
+ pass
71
+ try:
72
+ return run_git(repo, "rev-parse", "--verify", "-q", "HEAD").strip() or None
73
+ except subprocess.CalledProcessError:
74
+ return None
75
+
76
+
37
77
  def run_git(repo: str, *args: str) -> str:
38
78
  result = subprocess.run(
39
79
  ["git", "-C", repo, *args],
@@ -10,15 +10,17 @@ 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
19
21
  from blot.config import Config, load_config
20
22
  from blot.diff import is_trivial_line, line_hash, normalize_line, parse_unified_diff
21
- from blot.git import run_git
23
+ from blot.git import head_sha, run_git
22
24
  from blot.store import CommitRow, Store
23
25
 
24
26
  _RECORD, _FIELD = "\x1e", "\x1f"
@@ -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):
@@ -111,13 +144,15 @@ def build_index(
111
144
  ) -> IndexResult:
112
145
  """Index history up to `upto` into `db_path` (default .git/blot.db). Bench replays use both."""
113
146
  start = time.perf_counter()
114
- config = config or load_config(repo)
115
- head = _head(repo, upto)
147
+ head = head_sha(repo) if upto == "HEAD" else _head(repo, upto)
116
148
 
117
149
  store = Store.open(repo, db_path)
118
150
  last = store.get_meta("last_sha") if store else None
119
151
  if store:
120
152
  store.close()
153
+ if not full and head is not None and last == head: # already current: `blot check` hits this on every commit
154
+ return IndexResult(0, 0, time.perf_counter() - start, False)
155
+ config = config or load_config(repo)
121
156
  rebuild = full or not last or (head is not None and not _is_ancestor(repo, last, head))
122
157
  store = Store.create(repo, fresh=rebuild, path=db_path)
123
158
 
@@ -128,9 +163,7 @@ def build_index(
128
163
  return IndexResult(0, 0, time.perf_counter() - start, rebuild)
129
164
 
130
165
  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)
166
+ parsed = _read_history(repo, rev)
134
167
 
135
168
  fix_shas = []
136
169
  for info, date, paths in parsed:
@@ -11,10 +11,26 @@ def test_registry_has_three_checks():
11
11
  assert [c.id for c in REGISTRY] == ["removed-fix", "readded-bug", "forgot-related-file"]
12
12
 
13
13
 
14
- def test_check_without_index_never_blocks(make_repo, capsys):
14
+ def test_check_builds_the_index_on_first_run_then_refreshes_it(make_repo, capsys):
15
+ import subprocess
16
+
17
+ from test_checks import AFTER, BEFORE, GUARD
18
+
15
19
  repo = make_repo([("feat: init", {"a.py": "x = 1\n"})])
16
20
  assert main(["--repo", str(repo), "check"]) == 0
17
- assert "run `blot index`" in capsys.readouterr().err
21
+ assert "first run, indexing" in capsys.readouterr().err
22
+ assert main(["--repo", str(repo), "check"]) == 0
23
+ assert capsys.readouterr().err == "" # already indexed: silent
24
+
25
+ # a fix committed after the first check must be known to the next one (incremental refresh)
26
+ for message, content in (("Add app", BEFORE), ("Break reference cycle in App (fixes #3761)", AFTER)):
27
+ (repo / "app.py").write_text(content, encoding="utf-8")
28
+ subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True)
29
+ subprocess.run(["git", "-C", str(repo), "commit", "-q", "-m", message], check=True)
30
+ (repo / "app.py").write_text(AFTER.replace(GUARD, "self_ref = self"), encoding="utf-8")
31
+ subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True)
32
+ assert main(["--repo", str(repo), "check"]) == 0
33
+ assert "removed-fix" in capsys.readouterr().out
18
34
 
19
35
 
20
36
  def test_render_text():
@@ -0,0 +1,36 @@
1
+ import os
2
+ import subprocess
3
+
4
+ from blot.git import git_dir, head_sha, run_git
5
+
6
+
7
+ def test_head_sha_matches_git_in_every_ref_layout(make_repo, tmp_path, monkeypatch):
8
+ monkeypatch.delenv("GIT_DIR", raising=False)
9
+ repo = make_repo([("one", {"a.py": "x = 1\n"}), ("two", {"a.py": "x = 2\n"})])
10
+ rev = lambda path: run_git(str(path), "rev-parse", "HEAD").strip() # noqa: E731
11
+
12
+ assert head_sha(str(repo)) == rev(repo) # loose ref
13
+ run_git(str(repo), "pack-refs", "--all")
14
+ assert head_sha(str(repo)) == rev(repo) # packed-refs only
15
+ wt = tmp_path / "wt"
16
+ subprocess.run(["git", "-C", str(repo), "worktree", "add", "-q", "-b", "side", str(wt)], check=True)
17
+ assert head_sha(str(wt)) == rev(wt) # worktree: ref lives in the common dir
18
+ run_git(str(repo), "checkout", "-q", "--detach", "HEAD~1")
19
+ assert head_sha(str(repo)) == rev(repo) # detached HEAD
20
+ empty = tmp_path / "empty"
21
+ subprocess.run(["git", "init", "-q", str(empty)], check=True)
22
+ assert head_sha(str(empty)) is None # no commits yet
23
+
24
+
25
+ def test_git_dir_matches_git_from_root_subdir_and_worktree(make_repo, tmp_path, monkeypatch):
26
+ monkeypatch.delenv("GIT_DIR", raising=False)
27
+ repo = make_repo([("init", {"pkg/a.py": "x = 1\n"})])
28
+ expected = os.path.normcase(os.path.normpath(run_git(str(repo), "rev-parse", "--absolute-git-dir").strip()))
29
+
30
+ assert os.path.normcase(git_dir(str(repo))) == expected
31
+ assert os.path.normcase(git_dir(str(repo / "pkg"))) == expected
32
+
33
+ wt = tmp_path / "wt"
34
+ subprocess.run(["git", "-C", str(repo), "worktree", "add", "-q", str(wt)], check=True)
35
+ wt_expected = os.path.normcase(os.path.normpath(run_git(str(wt), "rev-parse", "--absolute-git-dir").strip()))
36
+ assert os.path.normcase(git_dir(str(wt))) == wt_expected
@@ -43,7 +43,7 @@ def test_real_commit_runs_hook_and_is_not_blocked(make_repo):
43
43
  subprocess.run(["git", "-C", str(repo), "add", "a.py"], check=True)
44
44
  done = subprocess.run(["git", "-C", str(repo), "commit", "-m", "change"], capture_output=True, text=True)
45
45
  assert done.returncode == 0
46
- assert "no index yet" in done.stderr # the hook really ran
46
+ assert "first run, indexing" in done.stderr # the hook really ran, and indexed the repo
47
47
 
48
48
 
49
49
  def test_uninstall_deletes_hook_blot_created(make_repo):
@@ -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
@@ -46,9 +46,8 @@ def test_why_traces_past_a_refactor(make_repo):
46
46
 
47
47
  def test_why_cli_and_errors(make_repo, capsys):
48
48
  repo = make_repo(history())
49
- assert main(["--repo", str(repo), "why", "core.py:1"]) == 1 # no index yet
50
- assert "run `blot index`" in capsys.readouterr().err
51
- main(["--repo", str(repo), "index", "--quiet"])
49
+ assert main(["--repo", str(repo), "why", "core.py:1"]) == 0 # builds the index itself
50
+ assert "first run, indexing" in capsys.readouterr().err
52
51
  assert main(["--repo", str(repo), "why", "missing.py:1"]) == 1
53
52
  assert "not tracked" in capsys.readouterr().err
54
53
  assert main(["--repo", str(repo), "why", "core.py:999"]) == 1
@@ -1,18 +0,0 @@
1
- import os
2
- import subprocess
3
-
4
- from blot.git import git_dir, run_git
5
-
6
-
7
- def test_git_dir_matches_git_from_root_subdir_and_worktree(make_repo, tmp_path, monkeypatch):
8
- monkeypatch.delenv("GIT_DIR", raising=False)
9
- repo = make_repo([("init", {"pkg/a.py": "x = 1\n"})])
10
- expected = os.path.normcase(os.path.normpath(run_git(str(repo), "rev-parse", "--absolute-git-dir").strip()))
11
-
12
- assert os.path.normcase(git_dir(str(repo))) == expected
13
- assert os.path.normcase(git_dir(str(repo / "pkg"))) == expected
14
-
15
- wt = tmp_path / "wt"
16
- subprocess.run(["git", "-C", str(repo), "worktree", "add", "-q", str(wt)], check=True)
17
- wt_expected = os.path.normcase(os.path.normpath(run_git(str(wt), "rev-parse", "--absolute-git-dir").strip()))
18
- assert os.path.normcase(git_dir(str(wt))) == wt_expected
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