gitmole 0.12.0__tar.gz → 0.13.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.
- {gitmole-0.12.0 → gitmole-0.13.1}/PKG-INFO +1 -1
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/__init__.py +1 -1
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/evaluate.py +53 -6
- gitmole-0.13.1/gitmole/szz.py +135 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/PKG-INFO +1 -1
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/SOURCES.txt +2 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_evaluate.py +32 -1
- gitmole-0.13.1/tests/test_szz.py +99 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/LICENSE +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/README.md +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/__main__.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/backtest.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/banner.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/blame.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/classify.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/clean.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/cli.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/compare.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/coupling.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/deps.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/duplicates.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/filetypes.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/findings.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/functions.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/hotspots.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/identity.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/knowledge.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/leaks.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/load.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/loss.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/maat.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/render.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/run.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/textfmt.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/trend.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole/watch.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/pyproject.toml +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/setup.cfg +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_backtest.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_banner.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_blame.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_classify.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_clean.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_cli.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_compare.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_coupling.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_deps.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_duplicates.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_filetypes.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_findings.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_functions.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_golden.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_hotspots.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_identity.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_knowledge.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_leaks.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_load.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_loss.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_maat.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_packaging.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_render.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_render_examples.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_run.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_textfmt.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_trend.py +0 -0
- {gitmole-0.12.0 → gitmole-0.13.1}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.1
|
|
4
4
|
Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/antvinni/gitmole
|
|
@@ -20,7 +20,7 @@ import os
|
|
|
20
20
|
import subprocess
|
|
21
21
|
import sys
|
|
22
22
|
|
|
23
|
-
from . import backtest, filetypes, identity, load, maat, trend, watch
|
|
23
|
+
from . import backtest, filetypes, identity, load, maat, szz, trend, watch
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def cutoffs(last_date: str, windows: int, horizon: int) -> list:
|
|
@@ -44,6 +44,32 @@ def fixed_between(commits: list, start: str, end: str) -> set:
|
|
|
44
44
|
for p, _, _ in c["files"] if not filetypes.is_test_path(p)}
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def induced_between(repo: str, commits: list, start: str, end: str, exclude=None) -> set:
|
|
48
|
+
"""Source files a commit before `start` made buggy, by R-SZZ over the fixes landing on or after
|
|
49
|
+
`start` and before `end`: the outcome is defect insertion the list could have known about, not
|
|
50
|
+
fix locality. A fix whose bug-inducing commit is inside the horizon is not counted, since no
|
|
51
|
+
list drawn before `start` could have named it."""
|
|
52
|
+
out = set()
|
|
53
|
+
for c in maat.in_window(maat.fix_commits(commits), start, end):
|
|
54
|
+
found = szz.bug_inducing(repo, c["hash"], exclude)
|
|
55
|
+
if found and found["date"] < start:
|
|
56
|
+
out.update(p for p in found["files"] if not filetypes.is_test_path(p))
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def labelled_between(commits: list, labels: dict, start: str, end: str) -> set:
|
|
61
|
+
"""Source files an independently labelled bug-inducing commit touched on or after `start` and
|
|
62
|
+
before `end` (ApacheJIT, Defectors: see szz.read_labels): the paths the label names, or every
|
|
63
|
+
source file the commit touched. Either side may be abbreviated."""
|
|
64
|
+
out = set()
|
|
65
|
+
for c in maat.in_window(commits, start, end):
|
|
66
|
+
paths = next((v for k, v in labels.items() if k.startswith(c["hash"]) or c["hash"].startswith(k)), "none")
|
|
67
|
+
if paths == "none":
|
|
68
|
+
continue
|
|
69
|
+
out.update(p for p in (paths if paths is not None else [p for p, _, _ in c["files"]]) if not filetypes.is_test_path(p))
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
47
73
|
def report_at(commits: list, t: str, size: dict, meta: dict, generated: list, vendored: list, ignored: set = frozenset()) -> dict:
|
|
48
74
|
"""The report watch.risks reads, from the commits before `t` (less the sweeps and the declared, as
|
|
49
75
|
the pipeline leaves them out), scc's listing of the tree at `t`, and that tree's own generated and
|
|
@@ -122,10 +148,11 @@ def score(report: dict, fixed: set, top: int) -> dict:
|
|
|
122
148
|
return out
|
|
123
149
|
|
|
124
150
|
|
|
125
|
-
def table(results: list) -> str:
|
|
126
|
-
"""results: [(t, files fixed that were in the pool, pool size, {variant: hits})], oldest first -> Markdown.
|
|
151
|
+
def table(results: list, noun: str = "fixed") -> str:
|
|
152
|
+
"""results: [(t, files fixed that were in the pool, pool size, {variant: hits})], oldest first -> Markdown.
|
|
153
|
+
`noun` says what the outcome is: fixed, bug-inducing (R-SZZ) or labelled."""
|
|
127
154
|
names = list(results[0][3]) if results else []
|
|
128
|
-
head = "| variant | " + " | ".join(f"{t} ({fixed} of {pool}
|
|
155
|
+
head = "| variant | " + " | ".join(f"{t} ({fixed} of {pool} {noun})" for t, fixed, pool, _ in results) + " | total |"
|
|
129
156
|
rule = "|---|" + "---:|" * (len(results) + 1)
|
|
130
157
|
rows = []
|
|
131
158
|
for name in names:
|
|
@@ -152,6 +179,8 @@ def main(argv=None) -> int:
|
|
|
152
179
|
p.add_argument("--windows", type=int, default=6)
|
|
153
180
|
p.add_argument("--horizon", type=int, default=6, metavar="MONTHS")
|
|
154
181
|
p.add_argument("--top", type=int, default=watch.WATCH_TOP)
|
|
182
|
+
p.add_argument("--szz", action="store_true", help="also score against R-SZZ bug-inducing commits (one git blame per fix and file: minutes)")
|
|
183
|
+
p.add_argument("--labels", metavar="CSV", help="also score against independent bug-inducing labels (ApacheJIT's CSV, Defectors' file rows, or one hash per line)")
|
|
155
184
|
args = p.parse_args(argv)
|
|
156
185
|
meta = load._read_json(args.out, "meta.json", {})
|
|
157
186
|
log_path = os.path.join(args.out, "log.txt")
|
|
@@ -165,7 +194,11 @@ def main(argv=None) -> int:
|
|
|
165
194
|
from . import run
|
|
166
195
|
declared = maat.read_ignore_revs(run.ignore_revs_files(args.repo))
|
|
167
196
|
ignored = {c["hash"] for c in commits if declared and maat.is_ignored(c["hash"], declared)}
|
|
168
|
-
|
|
197
|
+
labels = szz.read_labels(args.labels) if args.labels else None
|
|
198
|
+
if args.labels and not labels:
|
|
199
|
+
print(f"evaluate: no bug-inducing commits read from {args.labels}", file=sys.stderr)
|
|
200
|
+
return 2
|
|
201
|
+
results, induced_results, labelled_results = [], [], []
|
|
169
202
|
for t in cutoffs(meta["last_date"], args.windows, args.horizon):
|
|
170
203
|
rev = trend.rev_before(args.repo, t, end_of_day=False)
|
|
171
204
|
if not rev:
|
|
@@ -173,9 +206,17 @@ def main(argv=None) -> int:
|
|
|
173
206
|
size_json, generated, vendored = backtest.snapshot_at(args.repo, rev, args.out)
|
|
174
207
|
size = load.parse_scc(size_json, types)
|
|
175
208
|
report = report_at(commits, t, size, meta, generated, vendored, ignored)
|
|
176
|
-
|
|
209
|
+
end = months_after(t, args.horizon)
|
|
210
|
+
fixed = fixed_between(commits, t, end)
|
|
177
211
|
pool = set(variants(report)["churn"])
|
|
178
212
|
results.append((t, len(fixed & pool), len(pool), score(report, fixed, args.top)))
|
|
213
|
+
if args.szz:
|
|
214
|
+
vendor = tuple(vendored)
|
|
215
|
+
induced = induced_between(args.repo, commits, t, end, exclude=lambda p: p in generated or filetypes.is_vendored(p, vendor) or filetypes.is_sample_path(p))
|
|
216
|
+
induced_results.append((t, len(induced & pool), len(pool), score(report, induced, args.top)))
|
|
217
|
+
if labels:
|
|
218
|
+
marked = labelled_between(commits, labels, t, end)
|
|
219
|
+
labelled_results.append((t, len(marked & pool), len(pool), score(report, marked, args.top)))
|
|
179
220
|
print(f"evaluate: {t} done", file=sys.stderr)
|
|
180
221
|
if not results:
|
|
181
222
|
print("evaluate: no cut-off falls inside the history", file=sys.stderr)
|
|
@@ -183,6 +224,12 @@ def main(argv=None) -> int:
|
|
|
183
224
|
spread = ref_spread(args.repo)
|
|
184
225
|
print(f"### {meta.get('name', args.repo)}, top {args.top}, {args.horizon}-month horizon\n")
|
|
185
226
|
print(table(results))
|
|
227
|
+
if induced_results:
|
|
228
|
+
print(f"\nAgainst the files a commit before the cut-off made buggy, by R-SZZ over the fixes that followed (the most recent commit each fix's removed lines blame to):\n")
|
|
229
|
+
print(table(induced_results, noun="bug-inducing"))
|
|
230
|
+
if labelled_results:
|
|
231
|
+
print(f"\nAgainst the files the bug-inducing commits labelled in {os.path.basename(args.labels)} touched inside each window:\n")
|
|
232
|
+
print(table(labelled_results, noun="labelled"))
|
|
186
233
|
print(f"\n`--all` exports {spread['all']:,} commits ({spread['fix_all']:,} fixes); HEAD reaches {spread['head']:,} ({spread['fix_head']:,} fixes).")
|
|
187
234
|
return 0
|
|
188
235
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Bug-inducing commits by R-SZZ, from git alone, for the backtest's second outcome.
|
|
2
|
+
|
|
3
|
+
The watch list's backtest scores "a fix-labelled commit touched the file in the six months after
|
|
4
|
+
the cut-off": fix locality, which a changelog or a config file scores without ever being wrong.
|
|
5
|
+
The SZZ family gives the other label, defect insertion: the commit that wrote the lines a fix
|
|
6
|
+
removed. Rosa et al. (ICSE 2021, JSS 2023) measured the git-only variants against a
|
|
7
|
+
developer-informed oracle and found R-SZZ, which keeps only the most recent candidate per fix,
|
|
8
|
+
the best of them at precision 0.66 against 0.39 for keeping every candidate. This is that variant:
|
|
9
|
+
|
|
10
|
+
1. the lines a fix removed or changed, per modified file, from `git diff -U0 fix^ fix`;
|
|
11
|
+
2. `git blame -w -C -C` of the parent at those lines, so whitespace and moved code do not mislead;
|
|
12
|
+
3. files the caller excludes (tests, vendored, generated) are not candidates;
|
|
13
|
+
4. of the commits blamed, the most recent by author date is the bug-inducing one, with the files
|
|
14
|
+
whose blamed lines it wrote.
|
|
15
|
+
|
|
16
|
+
An insert-only fix blames nothing and R-SZZ finds nothing for it; that is the known blind spot.
|
|
17
|
+
A development tool behind `python -m gitmole.evaluate --szz`, not a pipeline step: one blame per
|
|
18
|
+
(fix, file) is minutes over a large history, and the report's own backtest stays the cheap one."""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import csv
|
|
22
|
+
import datetime as dt
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
|
|
26
|
+
from . import filetypes
|
|
27
|
+
|
|
28
|
+
_HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@")
|
|
29
|
+
_HEADER = re.compile(r"^([0-9a-f]{40,64}) \d+ \d+(?: \d+)?$")
|
|
30
|
+
_HASH = re.compile(r"^[0-9a-f]{7,64}$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _git(repo: str, *args) -> str:
|
|
34
|
+
return subprocess.run([*filetypes.GIT, *args], cwd=repo, capture_output=True, check=True).stdout.decode("utf-8", "replace")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def deleted_ranges(repo: str, fix: str) -> dict:
|
|
38
|
+
"""{path: [(start, count)]} of the parent-side lines a fix removed or changed, for every file it
|
|
39
|
+
modified (added and deleted files have no parent version to blame). An empty list is a file
|
|
40
|
+
that only gained lines."""
|
|
41
|
+
out, path = {}, None
|
|
42
|
+
text = _git(repo, "diff", "-U0", "--diff-filter=M", "--no-color", f"{fix}^", fix)
|
|
43
|
+
for line in text.split("\n"):
|
|
44
|
+
if line.startswith("diff --git "):
|
|
45
|
+
path = None
|
|
46
|
+
elif line.startswith("+++ b/"):
|
|
47
|
+
path = filetypes.unquote(line[6:])
|
|
48
|
+
out.setdefault(path, [])
|
|
49
|
+
elif path is not None and line.startswith("@@"):
|
|
50
|
+
m = _HUNK.match(line)
|
|
51
|
+
if not m:
|
|
52
|
+
continue
|
|
53
|
+
start, count = int(m.group(1)), int(m.group(2)) if m.group(2) is not None else 1
|
|
54
|
+
if count:
|
|
55
|
+
out[path].append((start, count))
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def blame_lines(repo: str, rev: str, path: str, ranges: list) -> dict:
|
|
60
|
+
"""{commit: {"time": author epoch, "lines": n}} for the given parent-side line ranges, blamed
|
|
61
|
+
with whitespace ignored and moves followed (-w -C -C: AG-SZZ's robustness for free)."""
|
|
62
|
+
if not ranges:
|
|
63
|
+
return {}
|
|
64
|
+
argv = ["blame", "-w", "-C", "-C", "--line-porcelain"]
|
|
65
|
+
for start, count in ranges:
|
|
66
|
+
argv += ["-L", f"{start},{start + count - 1}"]
|
|
67
|
+
try:
|
|
68
|
+
text = _git(repo, *argv, rev, "--", path)
|
|
69
|
+
except subprocess.CalledProcessError:
|
|
70
|
+
return {}
|
|
71
|
+
out, current = {}, None
|
|
72
|
+
for line in text.split("\n"):
|
|
73
|
+
m = _HEADER.match(line)
|
|
74
|
+
if m:
|
|
75
|
+
current = out.setdefault(m.group(1), {"time": 0, "lines": 0})
|
|
76
|
+
current["lines"] += 1
|
|
77
|
+
elif current is not None and line.startswith("author-time "):
|
|
78
|
+
current["time"] = int(line[12:])
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def bug_inducing(repo: str, fix: str, exclude=None):
|
|
83
|
+
"""{"commit", "date", "files"} for the R-SZZ pick of a fix: the most recent commit its deleted
|
|
84
|
+
lines blame to, and the files whose lines it wrote; None when nothing blames (an insert-only
|
|
85
|
+
fix, or every touched file excluded). `exclude(path)` says which files are not candidates; test
|
|
86
|
+
files never are."""
|
|
87
|
+
candidates = {}
|
|
88
|
+
for path, ranges in deleted_ranges(repo, fix).items():
|
|
89
|
+
if filetypes.is_test_path(path) or (exclude and exclude(path)):
|
|
90
|
+
continue
|
|
91
|
+
for commit, info in blame_lines(repo, f"{fix}^", path, ranges).items():
|
|
92
|
+
c = candidates.setdefault(commit, {"time": info["time"], "files": set()})
|
|
93
|
+
c["time"] = max(c["time"], info["time"])
|
|
94
|
+
c["files"].add(path)
|
|
95
|
+
if not candidates:
|
|
96
|
+
return None
|
|
97
|
+
commit = max(candidates, key=lambda c: (candidates[c]["time"], c))
|
|
98
|
+
when = dt.datetime.fromtimestamp(candidates[commit]["time"], dt.timezone.utc).date().isoformat()
|
|
99
|
+
return {"commit": commit, "date": when, "files": sorted(candidates[commit]["files"])}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_TRUE = {"true", "1", "yes", "buggy"}
|
|
103
|
+
_COMMIT_COLUMNS = ("commit_id", "commit", "hash", "sha", "bug_commit", "bic")
|
|
104
|
+
_PATH_COLUMNS = ("path", "file", "filepath", "file_path", "filename")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_labels(path: str) -> dict:
|
|
108
|
+
"""Independent bug-inducing labels: {commit: [paths] or None}. Three shapes: ApacheJIT's CSV
|
|
109
|
+
(a `commit_id` column and a `buggy` flag; only the buggy rows count), Defectors' file-level
|
|
110
|
+
rows (a commit column and a path column), or a bare list of hashes, one per line."""
|
|
111
|
+
out = {}
|
|
112
|
+
with open(path, encoding="utf-8", errors="replace", newline="") as fh:
|
|
113
|
+
rows = list(csv.reader(fh))
|
|
114
|
+
if not rows:
|
|
115
|
+
return out
|
|
116
|
+
head = [h.strip().lower() for h in rows[0]]
|
|
117
|
+
commit_col = next((i for i, h in enumerate(head) if h in _COMMIT_COLUMNS), None)
|
|
118
|
+
if commit_col is None:
|
|
119
|
+
for row in rows:
|
|
120
|
+
if row and _HASH.match(row[0].strip()):
|
|
121
|
+
out[row[0].strip()] = None
|
|
122
|
+
return out
|
|
123
|
+
buggy_col = next((i for i, h in enumerate(head) if h == "buggy"), None)
|
|
124
|
+
path_col = next((i for i, h in enumerate(head) if h in _PATH_COLUMNS), None)
|
|
125
|
+
for row in rows[1:]:
|
|
126
|
+
if len(row) <= commit_col or not row[commit_col].strip():
|
|
127
|
+
continue
|
|
128
|
+
if buggy_col is not None and (len(row) <= buggy_col or row[buggy_col].strip().lower() not in _TRUE):
|
|
129
|
+
continue
|
|
130
|
+
commit = row[commit_col].strip()
|
|
131
|
+
if path_col is not None and len(row) > path_col and row[path_col].strip():
|
|
132
|
+
out[commit] = (out.get(commit) or []) + [row[path_col].strip()]
|
|
133
|
+
else:
|
|
134
|
+
out.setdefault(commit, None)
|
|
135
|
+
return out
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.1
|
|
4
4
|
Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/antvinni/gitmole
|
|
@@ -26,6 +26,7 @@ gitmole/loss.py
|
|
|
26
26
|
gitmole/maat.py
|
|
27
27
|
gitmole/render.py
|
|
28
28
|
gitmole/run.py
|
|
29
|
+
gitmole/szz.py
|
|
29
30
|
gitmole/textfmt.py
|
|
30
31
|
gitmole/trend.py
|
|
31
32
|
gitmole/watch.py
|
|
@@ -61,6 +62,7 @@ tests/test_packaging.py
|
|
|
61
62
|
tests/test_render.py
|
|
62
63
|
tests/test_render_examples.py
|
|
63
64
|
tests/test_run.py
|
|
65
|
+
tests/test_szz.py
|
|
64
66
|
tests/test_textfmt.py
|
|
65
67
|
tests/test_trend.py
|
|
66
68
|
tests/test_watch.py
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import tempfile
|
|
1
4
|
import unittest
|
|
2
5
|
|
|
3
|
-
from gitmole import evaluate
|
|
6
|
+
from gitmole import evaluate, maat
|
|
7
|
+
from tests.test_szz import make_repo
|
|
4
8
|
|
|
5
9
|
|
|
6
10
|
def commit(date, subject, *files):
|
|
@@ -39,6 +43,32 @@ class Outcome(unittest.TestCase):
|
|
|
39
43
|
self.assertEqual(evaluate.fixed_between(commits, "2025-07-01", "2025-09-01"), {"core/b.py"}, "2,000 lines over the 99th percentile: tangled by size, credits nothing")
|
|
40
44
|
|
|
41
45
|
|
|
46
|
+
class Induced(unittest.TestCase):
|
|
47
|
+
def _log(self, d):
|
|
48
|
+
text = subprocess.run(["git", "log", "HEAD", "--numstat", "--date=iso-strict", "-M", "--pretty=format:--%h--%ad--%aN--%s"],
|
|
49
|
+
cwd=d, capture_output=True, text=True, check=True).stdout
|
|
50
|
+
return maat.parse_log(text)
|
|
51
|
+
|
|
52
|
+
def test_files_a_commit_before_the_cut_off_made_buggy_as_a_fix_inside_the_horizon_shows(self):
|
|
53
|
+
with tempfile.TemporaryDirectory() as d:
|
|
54
|
+
make_repo(d)
|
|
55
|
+
commits = self._log(d)
|
|
56
|
+
self.assertEqual(evaluate.induced_between(d, commits, "2025-03-15", "2025-09-15"), {"core/f.py"},
|
|
57
|
+
"the April fix blames the February commit, which is before the cut-off")
|
|
58
|
+
self.assertEqual(evaluate.induced_between(d, commits, "2025-01-15", "2025-09-15"), set(),
|
|
59
|
+
"the same fix, but the bug was planted after this cut-off: not something the list could have known")
|
|
60
|
+
self.assertEqual(evaluate.induced_between(d, commits, "2025-04-15", "2025-09-15"), set(), "the May fix only adds lines: R-SZZ finds nothing")
|
|
61
|
+
|
|
62
|
+
def test_labelled_commits_inside_the_horizon_name_their_source_files_or_the_paths_given(self):
|
|
63
|
+
with tempfile.TemporaryDirectory() as d:
|
|
64
|
+
by = make_repo(d)
|
|
65
|
+
commits = self._log(d)
|
|
66
|
+
labels = {by["plant it"][:9]: None, by["change d, add f, touch g"]: ["core/g.py", "docs/x.md"], by["fix: add z"]: None}
|
|
67
|
+
self.assertEqual(evaluate.labelled_between(commits, labels, "2025-01-15", "2025-02-15"), {"core/f.py"}, "an abbreviated label still matches")
|
|
68
|
+
self.assertEqual(evaluate.labelled_between(commits, labels, "2025-02-15", "2025-03-15"), {"core/g.py", "docs/x.md"}, "the paths the label names")
|
|
69
|
+
self.assertEqual(evaluate.labelled_between(commits, labels, "2025-04-15", "2025-06-15"), {"core/g.py"})
|
|
70
|
+
|
|
71
|
+
|
|
42
72
|
class Score(unittest.TestCase):
|
|
43
73
|
def test_the_report_at_t_knows_nothing_after_t(self):
|
|
44
74
|
r = evaluate.report_at(COMMITS, "2025-06-01", SIZE, {"bots": []}, [], [])
|
|
@@ -103,6 +133,7 @@ class Table(unittest.TestCase):
|
|
|
103
133
|
"| churn | 1 | 2 | 3 |",
|
|
104
134
|
"| random (expected) | 0.4 | 0.6 | 1.0 |",
|
|
105
135
|
])
|
|
136
|
+
self.assertIn("(3 of 40 bug-inducing)", evaluate.table([("2025-02-28", 3, 40, {"churn": 1})], noun="bug-inducing"))
|
|
106
137
|
|
|
107
138
|
|
|
108
139
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from gitmole import szz
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_repo(d):
|
|
10
|
+
"""A file with a bug planted in February, an unrelated edit in March, the fix in April; a test
|
|
11
|
+
file the fix also touches; and an insert-only fix in May."""
|
|
12
|
+
def git(*args, date):
|
|
13
|
+
env = dict(os.environ, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null", GIT_AUTHOR_NAME="Ann", GIT_AUTHOR_EMAIL="a@x",
|
|
14
|
+
GIT_COMMITTER_NAME="Ann", GIT_COMMITTER_EMAIL="a@x", GIT_AUTHOR_DATE=f"{date}T00:00:00", GIT_COMMITTER_DATE=f"{date}T00:00:00")
|
|
15
|
+
subprocess.run(["git", *args], cwd=d, check=True, capture_output=True, env=env)
|
|
16
|
+
|
|
17
|
+
def write(path, text):
|
|
18
|
+
os.makedirs(os.path.dirname(os.path.join(d, path)) or d, exist_ok=True)
|
|
19
|
+
with open(os.path.join(d, path), "w") as fh:
|
|
20
|
+
fh.write(text)
|
|
21
|
+
git("init", "-q", "-b", "main", date="2025-01-01")
|
|
22
|
+
write("core/f.py", "a\nb\nc\nd\ne\n")
|
|
23
|
+
write("core/g.py", "x\ny\n")
|
|
24
|
+
write("tests/test_f.py", "t\n")
|
|
25
|
+
git("add", "-A", date="2025-01-01")
|
|
26
|
+
git("commit", "-q", "-m", "one", date="2025-01-01")
|
|
27
|
+
write("core/f.py", "a\nB\nc\nd\ne\n")
|
|
28
|
+
git("commit", "-q", "-am", "plant it", date="2025-02-01")
|
|
29
|
+
write("core/f.py", "a\nB\nc\nD\ne\nf\n")
|
|
30
|
+
write("core/g.py", "x\nY\n")
|
|
31
|
+
git("commit", "-q", "-am", "change d, add f, touch g", date="2025-03-01")
|
|
32
|
+
write("core/f.py", "a\nb\nc\nD\ne\nf\n")
|
|
33
|
+
write("tests/test_f.py", "t\nu\n")
|
|
34
|
+
git("commit", "-q", "-am", "fix: b", date="2025-04-01")
|
|
35
|
+
write("core/g.py", "x\nY\nz\n")
|
|
36
|
+
git("commit", "-q", "-am", "fix: add z", date="2025-05-01")
|
|
37
|
+
return {line.split(" ", 1)[1]: line.split(" ", 1)[0] for line in
|
|
38
|
+
subprocess.run(["git", "log", "--format=%H %s", "HEAD"], cwd=d, capture_output=True, text=True, check=True).stdout.splitlines()
|
|
39
|
+
if " " in line}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DeletedRanges(unittest.TestCase):
|
|
43
|
+
def test_the_parent_side_lines_a_fix_removed_or_changed_per_modified_file(self):
|
|
44
|
+
with tempfile.TemporaryDirectory() as d:
|
|
45
|
+
by = make_repo(d)
|
|
46
|
+
self.assertEqual(szz.deleted_ranges(d, by["fix: b"]), {"core/f.py": [(2, 1)], "tests/test_f.py": []},
|
|
47
|
+
"line 2 of the parent's f.py was changed; the test file only gained a line")
|
|
48
|
+
self.assertEqual(szz.deleted_ranges(d, by["plant it"]), {"core/f.py": [(2, 1)]})
|
|
49
|
+
self.assertEqual(szz.deleted_ranges(d, by["change d, add f, touch g"]), {"core/f.py": [(4, 1)], "core/g.py": [(2, 1)]}, "an added line has no parent side")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BugInducing(unittest.TestCase):
|
|
53
|
+
def test_the_most_recent_commit_blamed_for_a_fixs_deleted_lines_with_its_files(self):
|
|
54
|
+
with tempfile.TemporaryDirectory() as d:
|
|
55
|
+
by = make_repo(d)
|
|
56
|
+
found = szz.bug_inducing(d, by["fix: b"])
|
|
57
|
+
self.assertEqual(found, {"commit": by["plant it"], "date": "2025-02-01", "files": ["core/f.py"]},
|
|
58
|
+
"the fix removed line 2, which the February commit wrote; the March commit is not blamed")
|
|
59
|
+
self.assertIsNone(szz.bug_inducing(d, by["fix: add z"]), "an insert-only fix blames nothing: R-SZZ finds nothing for it")
|
|
60
|
+
|
|
61
|
+
def test_files_the_caller_excludes_are_not_candidates(self):
|
|
62
|
+
with tempfile.TemporaryDirectory() as d:
|
|
63
|
+
by = make_repo(d)
|
|
64
|
+
self.assertIsNone(szz.bug_inducing(d, by["fix: b"], exclude=lambda p: p.startswith("core/")))
|
|
65
|
+
|
|
66
|
+
def test_the_most_recent_of_several_candidates_wins(self):
|
|
67
|
+
# R-SZZ: of the commits a fix's deleted lines blame to, only the latest is kept; Rosa et al. measured
|
|
68
|
+
# that rule at precision 0.66 against 0.39 for keeping them all
|
|
69
|
+
with tempfile.TemporaryDirectory() as d:
|
|
70
|
+
by = make_repo(d)
|
|
71
|
+
def git(*args, date):
|
|
72
|
+
env = dict(os.environ, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null", GIT_AUTHOR_NAME="Ann", GIT_AUTHOR_EMAIL="a@x",
|
|
73
|
+
GIT_COMMITTER_NAME="Ann", GIT_COMMITTER_EMAIL="a@x", GIT_AUTHOR_DATE=f"{date}T00:00:00", GIT_COMMITTER_DATE=f"{date}T00:00:00")
|
|
74
|
+
subprocess.run(["git", *args], cwd=d, check=True, capture_output=True, env=env)
|
|
75
|
+
with open(os.path.join(d, "core/f.py"), "w") as fh:
|
|
76
|
+
fh.write("a\nb\nc\nd\ne\nF\n") # rewrites line 4 (March's D) and line 6 (March's f) and line 1 (January's a)... only b and F
|
|
77
|
+
git("commit", "-q", "-am", "fix: d and f", date="2025-06-01")
|
|
78
|
+
fix = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True, text=True).stdout.strip()
|
|
79
|
+
found = szz.bug_inducing(d, fix)
|
|
80
|
+
self.assertEqual((found["commit"], found["date"], found["files"]), (by["change d, add f, touch g"], "2025-03-01", ["core/f.py"]))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Labels(unittest.TestCase):
|
|
84
|
+
def test_a_csv_of_buggy_commits_with_or_without_paths(self):
|
|
85
|
+
with tempfile.TemporaryDirectory() as d:
|
|
86
|
+
path = os.path.join(d, "labels.csv")
|
|
87
|
+
with open(path, "w") as fh:
|
|
88
|
+
fh.write("commit_id,project,buggy\nabc1234,x,True\ndef5678,x,False\n0123abc,x,1\n")
|
|
89
|
+
self.assertEqual(szz.read_labels(path), {"abc1234": None, "0123abc": None}, "ApacheJIT's shape: a buggy flag per commit")
|
|
90
|
+
with open(path, "w") as fh:
|
|
91
|
+
fh.write("commit,path\nabc1234,src/a.py\nabc1234,src/b.py\n")
|
|
92
|
+
self.assertEqual(szz.read_labels(path), {"abc1234": ["src/a.py", "src/b.py"]}, "Defectors' shape: the files a buggy commit made buggy")
|
|
93
|
+
with open(path, "w") as fh:
|
|
94
|
+
fh.write("abc1234\n0123abc\n")
|
|
95
|
+
self.assertEqual(szz.read_labels(path), {"abc1234": None, "0123abc": None}, "a bare list of hashes")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
unittest.main()
|
|
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
|