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.
- {blot_git-0.3.0 → blot_git-0.3.2}/.pre-commit-hooks.yaml +4 -1
- {blot_git-0.3.0 → blot_git-0.3.2}/PKG-INFO +2 -2
- {blot_git-0.3.0 → blot_git-0.3.2}/README.md +1 -1
- {blot_git-0.3.0 → blot_git-0.3.2}/docs/PYPI.md +1 -1
- {blot_git-0.3.0 → blot_git-0.3.2}/pyproject.toml +1 -1
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/__init__.py +1 -1
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/cli.py +20 -12
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/git.py +40 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/index.py +42 -9
- {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_cli.py +18 -2
- blot_git-0.3.2/tests/test_git.py +36 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_hooks.py +1 -1
- {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_index.py +14 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_why_github.py +2 -3
- blot_git-0.3.0/tests/test_git.py +0 -18
- {blot_git-0.3.0 → blot_git-0.3.2}/.gitattributes +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/.github/pull_request_template.md +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/.github/workflows/ci.yml +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/.github/workflows/release.yml +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/.gitignore +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/CONTRIBUTING.md +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/LICENSE +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/demo/demo.gif +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/demo/make_demo.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/alert.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/faces.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/happy.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/idle.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/sad.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/sleepy.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/walk.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/blue/worried.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/build_sprites.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/alert.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/faces.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/happy.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/idle.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/sad.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/sleepy.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/walk.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/mustard/worried.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/alert.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/faces.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/happy.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/idle.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/sad.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/sleepy.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/walk.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/orange/worried.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/alert.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/faces.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/happy.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/idle.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/sad.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/sleepy.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/walk.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/assets/sora/sage/worried.svg +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/bench/README.md +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/bench/RESULTS.md +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/bench/cases.json +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/docs/EXPLAINER.md +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/docs/PRD.md +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/docs/competitors.md +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/__main__.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/bench.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/checks/__init__.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/checks/base.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/checks/cochange.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/checks/readded_bug.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/checks/removed_fix.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/classify.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/config.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/diff.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/github.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/hooks.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/report.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/store.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/trace.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/src/blot/why.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/tests/conftest.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_bench.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_checks.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_classify.py +0 -0
- {blot_git-0.3.0 → blot_git-0.3.2}/tests/test_config.py +0 -0
- {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.
|
|
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.
|
|
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 #
|
|
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 #
|
|
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 #
|
|
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.
|
|
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" }
|
|
@@ -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 =
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 "
|
|
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"]) ==
|
|
50
|
-
assert "run
|
|
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
|
blot_git-0.3.0/tests/test_git.py
DELETED
|
@@ -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
|
|
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
|
|
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
|