gitmole 0.3.0__py3-none-any.whl
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/__init__.py +3 -0
- gitmole/__main__.py +6 -0
- gitmole/backtest.py +82 -0
- gitmole/banner.py +107 -0
- gitmole/blame.py +135 -0
- gitmole/cli.py +439 -0
- gitmole/filetypes.py +78 -0
- gitmole/findings.py +405 -0
- gitmole/functions.py +108 -0
- gitmole/hotspots.py +18 -0
- gitmole/identity.py +66 -0
- gitmole/knowledge.py +55 -0
- gitmole/leaks.py +124 -0
- gitmole/load.py +253 -0
- gitmole/loss.py +44 -0
- gitmole/maat.py +299 -0
- gitmole/render.py +736 -0
- gitmole/run.py +407 -0
- gitmole/textfmt.py +91 -0
- gitmole/trend.py +154 -0
- gitmole/watch.py +172 -0
- gitmole-0.3.0.dist-info/METADATA +469 -0
- gitmole-0.3.0.dist-info/RECORD +27 -0
- gitmole-0.3.0.dist-info/WHEEL +5 -0
- gitmole-0.3.0.dist-info/entry_points.txt +2 -0
- gitmole-0.3.0.dist-info/licenses/LICENSE +21 -0
- gitmole-0.3.0.dist-info/top_level.txt +1 -0
gitmole/leaks.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""gitleaks, with the secret values kept out of the output directory.
|
|
3
|
+
|
|
4
|
+
gitmole runs this as the gitleaks step: `python3 leaks.py OUT_JSON`, from inside the repository.
|
|
5
|
+
gitleaks writes its JSON report to our stdout, so the raw report is never a file; each value is
|
|
6
|
+
replaced by a short keyed hash (enough to tell one value repeated in many places from many values)
|
|
7
|
+
and a flag for shapes that cannot be a live secret, and only that is written. The key is random,
|
|
8
|
+
made for one report and never stored, so a hash in secrets.json cannot be checked against a list of
|
|
9
|
+
likely values; the price is that hashes from two runs cannot be compared, which nothing does. The report never needed the
|
|
10
|
+
values: it names the rule, the file and the commit. Standalone, like maat.py and blame.py.
|
|
11
|
+
|
|
12
|
+
The same module groups the loaded rows for the findings and the report footer.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from . import filetypes
|
|
27
|
+
except ImportError: # run as a script: the package directory is sys.path[0]
|
|
28
|
+
import filetypes
|
|
29
|
+
|
|
30
|
+
ARGV = ["gitleaks", "git", "--no-banner", "--report-format", "json", "--report-path", "-", "--exit-code", "0"]
|
|
31
|
+
RAW_FIELDS = ("Secret", "Match", "Line", "Message") # the value, the text around it, and the commit message, which can quote it
|
|
32
|
+
|
|
33
|
+
# A version string (5.0.0-1667386184.dfbbb54) and a token shortened with an ellipsis are the only shapes
|
|
34
|
+
# skipped. Nothing is skipped by prefix: a public and a private key of the same service often share one.
|
|
35
|
+
_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def new_key() -> bytes:
|
|
39
|
+
"""A random key for one report. Never written anywhere."""
|
|
40
|
+
return os.urandom(32)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def digest(value: str, key: bytes) -> str:
|
|
44
|
+
"""HMAC-SHA256 of the value under the report's key, cut to 12 hex characters: equal values in one
|
|
45
|
+
report share it, and without the key it says nothing about the value."""
|
|
46
|
+
return hmac.new(key, value.encode("utf-8", "surrogateescape"), hashlib.sha256).hexdigest()[:12]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_placeholder(value: str) -> bool:
|
|
50
|
+
value = value or ""
|
|
51
|
+
return bool(_VERSION.match(value)) or value.endswith("...") or value.endswith("…")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sanitise(rows: list) -> list:
|
|
55
|
+
key = new_key() # one key for the whole report, so repeats of a value still group
|
|
56
|
+
out = []
|
|
57
|
+
for r in rows:
|
|
58
|
+
value = r.get("Secret") or ""
|
|
59
|
+
clean = {k: v for k, v in r.items() if k not in RAW_FIELDS}
|
|
60
|
+
clean["SecretHash"] = digest(value, key)
|
|
61
|
+
clean["Placeholder"] = is_placeholder(value)
|
|
62
|
+
out.append(clean)
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def group(rows: list) -> list:
|
|
67
|
+
"""One entry per distinct secret value (placeholders left out): its rule, the files and commits it
|
|
68
|
+
appears in, the number of distinct places (commit, file, line), and whether every place is a test
|
|
69
|
+
file. Values that appear in source come first, then the most widespread."""
|
|
70
|
+
groups, order = {}, []
|
|
71
|
+
for i, r in enumerate(rows):
|
|
72
|
+
if r.get("placeholder"):
|
|
73
|
+
continue
|
|
74
|
+
key = r.get("value") or ("row", i)
|
|
75
|
+
if key not in groups:
|
|
76
|
+
groups[key] = {"value": r.get("value"), "rule": r["rule"], "files": [], "commits": [], "_places": set(), "test": True}
|
|
77
|
+
order.append(key)
|
|
78
|
+
g = groups[key]
|
|
79
|
+
if r["file"] not in g["files"]:
|
|
80
|
+
g["files"].append(r["file"])
|
|
81
|
+
if r["commit"] not in g["commits"]:
|
|
82
|
+
g["commits"].append(r["commit"])
|
|
83
|
+
g["_places"].add((r["commit"], r["file"], r.get("line")))
|
|
84
|
+
g["test"] = g["test"] and filetypes.is_test_path(r["file"])
|
|
85
|
+
out = []
|
|
86
|
+
for key in order:
|
|
87
|
+
g = groups[key]
|
|
88
|
+
places = g.pop("_places")
|
|
89
|
+
out.append({**g, "places": len(places)})
|
|
90
|
+
out.sort(key=lambda g: (g["test"], -g["places"])) # stable: first-seen order breaks ties
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def placeholders(rows: list) -> int:
|
|
95
|
+
"""Distinct places whose value had a placeholder shape."""
|
|
96
|
+
return len({(r["commit"], r["file"], r.get("line")) for r in rows if r.get("placeholder")})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv=None) -> int:
|
|
100
|
+
args = sys.argv[1:] if argv is None else argv
|
|
101
|
+
if len(args) != 1:
|
|
102
|
+
print("usage: leaks.py OUT_JSON", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
target = args[0]
|
|
105
|
+
# stderr is inherited, so gitleaks' own log lands in run.log as before
|
|
106
|
+
proc = subprocess.run(ARGV, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
|
|
107
|
+
if proc.returncode != 0:
|
|
108
|
+
print(f"leaks.py: gitleaks exited {proc.returncode}; no report written", file=sys.stderr)
|
|
109
|
+
return proc.returncode
|
|
110
|
+
text = proc.stdout.decode("utf-8", "surrogateescape").strip()
|
|
111
|
+
rows = sanitise(json.loads(text) if text else [])
|
|
112
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(target)), prefix=".secrets-", suffix=".json")
|
|
113
|
+
try:
|
|
114
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
115
|
+
json.dump(rows, fh, indent=1)
|
|
116
|
+
os.replace(tmp, target)
|
|
117
|
+
finally:
|
|
118
|
+
if os.path.exists(tmp):
|
|
119
|
+
os.remove(tmp)
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
sys.exit(main())
|
gitmole/load.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Parsers for the files the tools write. Each takes text and returns plain data."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from collections import Counter, OrderedDict
|
|
10
|
+
|
|
11
|
+
from . import filetypes, identity, leaks
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _rel(path: str) -> str:
|
|
15
|
+
"""Tools started in the repo print './x'; the log and blame say 'x'."""
|
|
16
|
+
return path[2:] if path.startswith("./") else path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _only(rows: list, types) -> list:
|
|
20
|
+
"""scc's language rows with the files outside `types` dropped and the totals rebuilt from what is
|
|
21
|
+
left. A row without per-file data (an older size.json) is kept as it is."""
|
|
22
|
+
out = []
|
|
23
|
+
for r in rows:
|
|
24
|
+
files = r.get("Files")
|
|
25
|
+
if files is None:
|
|
26
|
+
out.append(r)
|
|
27
|
+
continue
|
|
28
|
+
kept = [f for f in files if filetypes.matches(_rel(f.get("Location", "")), types)]
|
|
29
|
+
if kept:
|
|
30
|
+
out.append({**r, "Count": len(kept), "Files": kept,
|
|
31
|
+
**{k: sum(f.get(k, 0) for f in kept) for k in ("Code", "Comment", "Blank", "Complexity")}})
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_scc(text: str, types=None) -> dict:
|
|
36
|
+
"""scc --by-file JSON as languages and per-file rows. `types` (as filetypes.parse gives it: a
|
|
37
|
+
set, or None for everything) keeps only the code files, so the size matches the other tables."""
|
|
38
|
+
rows = json.loads(text) if text.strip() else []
|
|
39
|
+
if types is not None:
|
|
40
|
+
rows = _only(rows, types)
|
|
41
|
+
languages = sorted(
|
|
42
|
+
(
|
|
43
|
+
{
|
|
44
|
+
"name": r["Name"],
|
|
45
|
+
"files": r["Count"],
|
|
46
|
+
"code": r["Code"],
|
|
47
|
+
"comment": r["Comment"],
|
|
48
|
+
"blank": r["Blank"],
|
|
49
|
+
"complexity": r["Complexity"],
|
|
50
|
+
}
|
|
51
|
+
for r in rows
|
|
52
|
+
),
|
|
53
|
+
key=lambda r: -r["code"],
|
|
54
|
+
)
|
|
55
|
+
files = {}
|
|
56
|
+
for r in rows:
|
|
57
|
+
for f in r.get("Files", []) or []:
|
|
58
|
+
files[_rel(f.get("Location", ""))] = {"code": f.get("Code", 0), "complexity": f.get("Complexity", 0)}
|
|
59
|
+
return {
|
|
60
|
+
"languages": languages,
|
|
61
|
+
"total_code": sum(r["code"] for r in languages),
|
|
62
|
+
"total_files": sum(r["files"] for r in languages),
|
|
63
|
+
"files": files,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
NUMERIC_COLUMNS = {"n-revs", "degree", "average-revs", "n-authors", "age-months", "added", "deleted", "n-fixes", "recent-fixes"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_maat_csv(text: str) -> list:
|
|
71
|
+
"""Rows as dicts. Only known numeric columns become ints; a file or author named 2024 stays a string."""
|
|
72
|
+
if not text.strip():
|
|
73
|
+
return []
|
|
74
|
+
out = []
|
|
75
|
+
for row in csv.DictReader(io.StringIO(text)):
|
|
76
|
+
out.append({k: (_num(v) if k in NUMERIC_COLUMNS else v) for k, v in row.items()})
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _num(v):
|
|
81
|
+
"""An int for a numeric cell; 0 for a missing, empty or garbage one (a row cut short by a killed step)."""
|
|
82
|
+
try:
|
|
83
|
+
return int(v)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_SIZER_ROW = re.compile(r"^\|(?P<pad> *)(?P<name>.*?)\s*(?:\[(?P<ref>\d+)\])?\s*\|\s*(?P<value>.*?)\s*\|\s*(?P<concern>\**)\s*\|$")
|
|
89
|
+
_SIZER_NOTE = re.compile(r"^\[(?P<ref>\d+)\]\s+\S+\s+\((?:[^:]+:)?(?P<path>[^)]*)\)")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_git_sizer(text: str) -> list:
|
|
93
|
+
notes = {}
|
|
94
|
+
for line in text.splitlines():
|
|
95
|
+
m = _SIZER_NOTE.match(line)
|
|
96
|
+
if m:
|
|
97
|
+
notes[m.group("ref")] = m.group("path")
|
|
98
|
+
|
|
99
|
+
# Two shapes of section: "Overall repository size" and "Biggest objects" have sub-headers
|
|
100
|
+
# ("* Blobs") with their metrics indented under them; "History structure" and "Biggest
|
|
101
|
+
# checkouts" list their metrics directly ("* Number of files"). A starred line with a value
|
|
102
|
+
# is a metric, a starred line without one is a sub-header, an unstarred line is a section.
|
|
103
|
+
rows, section, sub = [], "", ""
|
|
104
|
+
for line in text.splitlines():
|
|
105
|
+
m = _SIZER_ROW.match(line)
|
|
106
|
+
if not m:
|
|
107
|
+
continue
|
|
108
|
+
indent = len(m.group("pad")) - 1
|
|
109
|
+
raw = m.group("name").strip()
|
|
110
|
+
name = raw.lstrip("* ").strip()
|
|
111
|
+
if not name or name == "Name" or name.startswith("---"):
|
|
112
|
+
continue
|
|
113
|
+
if indent == 0 and not raw.startswith("*"):
|
|
114
|
+
section = sub = name
|
|
115
|
+
continue
|
|
116
|
+
if indent == 0 and not m.group("value"):
|
|
117
|
+
sub = name
|
|
118
|
+
continue
|
|
119
|
+
if not m.group("concern"):
|
|
120
|
+
continue
|
|
121
|
+
rows.append({
|
|
122
|
+
"name": f"{sub if indent else section}: {name}",
|
|
123
|
+
"value": m.group("value"),
|
|
124
|
+
"concern": len(m.group("concern")),
|
|
125
|
+
"ref": notes.get(m.group("ref") or "", ""),
|
|
126
|
+
})
|
|
127
|
+
return rows
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_theseus(text: str) -> dict:
|
|
131
|
+
d = json.loads(text)
|
|
132
|
+
return OrderedDict((label, d["y"][i][-1]) for i, label in enumerate(d["labels"]))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_authors_log(text: str) -> list:
|
|
136
|
+
counts = Counter()
|
|
137
|
+
for line in text.splitlines():
|
|
138
|
+
if "\t" not in line:
|
|
139
|
+
continue
|
|
140
|
+
name, email = line.split("\t", 1)
|
|
141
|
+
counts[(name, email)] += 1
|
|
142
|
+
return [
|
|
143
|
+
{"name": n, "email": e, "commits": c}
|
|
144
|
+
for (n, e), c in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_functions(text: str) -> list:
|
|
149
|
+
"""lizard --csv rows: nloc, ccn, tokens, params, length, location, file, function, long name, start, end."""
|
|
150
|
+
rows = []
|
|
151
|
+
for r in csv.reader(io.StringIO(text)):
|
|
152
|
+
if len(r) < 11:
|
|
153
|
+
continue
|
|
154
|
+
rows.append({"file": _rel(r[6]), "function": r[7], "ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]),
|
|
155
|
+
"start": _num(r[9]), "end": _num(r[10])})
|
|
156
|
+
return rows
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
_DUP_PLACE = re.compile(r"^(.+?):(\d+) ~ (\d+)$")
|
|
160
|
+
_DUP_RATE = re.compile(r"Total duplicate rate:\s*([\d.]+)%")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_duplicates(text: str) -> dict:
|
|
164
|
+
"""lizard -Eduplicate output: blocks of 'path:start ~ end' lines and the overall rate."""
|
|
165
|
+
blocks, current = [], None
|
|
166
|
+
for line in text.splitlines():
|
|
167
|
+
line = line.rstrip()
|
|
168
|
+
if line == "Duplicate block:":
|
|
169
|
+
current = []
|
|
170
|
+
elif current is not None:
|
|
171
|
+
m = _DUP_PLACE.match(line)
|
|
172
|
+
if m:
|
|
173
|
+
current.append((_rel(m.group(1)), int(m.group(2)), int(m.group(3))))
|
|
174
|
+
elif line.startswith("^^^"):
|
|
175
|
+
if current:
|
|
176
|
+
blocks.append({"lines": current[0][2] - current[0][1] + 1, "places": sorted(current)})
|
|
177
|
+
current = None
|
|
178
|
+
m = _DUP_RATE.search(text)
|
|
179
|
+
return {"rate": float(m.group(1)) if m else None, "blocks": blocks}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_secrets(text: str) -> list:
|
|
183
|
+
"""gitleaks rows as rule, file, short commit, line, fingerprint, the hashed value and the placeholder
|
|
184
|
+
flag. A report written before values were hashed still has them: hash them here, keep nothing raw."""
|
|
185
|
+
rows = json.loads(text) if text.strip() else []
|
|
186
|
+
key = leaks.new_key() # for an older report with raw values: one key per read, as the wrapper does per run
|
|
187
|
+
out = []
|
|
188
|
+
for r in rows:
|
|
189
|
+
if "SecretHash" in r:
|
|
190
|
+
value, placeholder = r["SecretHash"], bool(r.get("Placeholder"))
|
|
191
|
+
elif r.get("Secret"):
|
|
192
|
+
value, placeholder = leaks.digest(r["Secret"], key), leaks.is_placeholder(r["Secret"])
|
|
193
|
+
else:
|
|
194
|
+
value, placeholder = None, False
|
|
195
|
+
out.append({"rule": r.get("RuleID", ""), "file": r.get("File", ""), "commit": r.get("Commit", "")[:7], "line": r.get("StartLine"),
|
|
196
|
+
"fingerprint": r.get("Fingerprint", ""), "value": value, "placeholder": placeholder})
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _read(out_dir: str, name: str) -> str:
|
|
201
|
+
path = os.path.join(out_dir, name)
|
|
202
|
+
if not os.path.exists(path):
|
|
203
|
+
return ""
|
|
204
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
205
|
+
return fh.read()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _read_json(out_dir: str, name: str, default):
|
|
209
|
+
"""`default` for a missing file or one a killed step left truncated or malformed."""
|
|
210
|
+
text = _read(out_dir, name)
|
|
211
|
+
if not text:
|
|
212
|
+
return default
|
|
213
|
+
try:
|
|
214
|
+
return json.loads(text)
|
|
215
|
+
except json.JSONDecodeError:
|
|
216
|
+
return default
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def load_report(out_dir: str, nested: bool = True) -> dict:
|
|
220
|
+
"""Read every output file gitmole writes. Missing optional files become empty values.
|
|
221
|
+
|
|
222
|
+
`nested`: also load the backtest sub-report (out_dir/backtest), one level deep only."""
|
|
223
|
+
meta = json.loads(_read(out_dir, "meta.json") or "{}")
|
|
224
|
+
cohorts = _read(out_dir, "theseus/cohorts.json")
|
|
225
|
+
authors = _read(out_dir, "theseus/authors.json")
|
|
226
|
+
canonical = dict(meta["aliases"]) if "aliases" in meta else identity.canonical_names(meta.get("identities") or [])
|
|
227
|
+
surviving = OrderedDict()
|
|
228
|
+
for name, lines in (parse_theseus(authors) if authors else {}).items():
|
|
229
|
+
key = canonical.get(name, name)
|
|
230
|
+
surviving[key] = surviving.get(key, 0) + lines
|
|
231
|
+
return {
|
|
232
|
+
"out_dir": out_dir,
|
|
233
|
+
"meta": meta,
|
|
234
|
+
# a run records its --file-types spec (None for the default list); a run from before that record
|
|
235
|
+
# was measured unfiltered, so it is re-rendered unfiltered rather than with a guessed list
|
|
236
|
+
"size": parse_scc(_read(out_dir, "size.json"), filetypes.parse(meta["file_types"]) if "file_types" in meta else None),
|
|
237
|
+
"revisions": parse_maat_csv(_read(out_dir, "maat-revisions.csv")),
|
|
238
|
+
"coupling": parse_maat_csv(_read(out_dir, "maat-coupling.csv")),
|
|
239
|
+
"authors": parse_maat_csv(_read(out_dir, "maat-authors.csv")),
|
|
240
|
+
"age": parse_maat_csv(_read(out_dir, "maat-age.csv")),
|
|
241
|
+
"ownership": parse_maat_csv(_read(out_dir, "maat-entity-ownership.csv")),
|
|
242
|
+
"fixes": parse_maat_csv(_read(out_dir, "maat-fixes.csv")),
|
|
243
|
+
"sizer": parse_git_sizer(_read(out_dir, "repo-health.txt")),
|
|
244
|
+
"cohorts": parse_theseus(cohorts) if cohorts else {},
|
|
245
|
+
"theseus_authors": surviving,
|
|
246
|
+
"secrets": parse_secrets(_read(out_dir, "secrets.json")),
|
|
247
|
+
"activity": _read_json(out_dir, "activity.json", {}),
|
|
248
|
+
"functions": parse_functions(_read(out_dir, "functions.csv")),
|
|
249
|
+
"duplicates": parse_duplicates(_read(out_dir, "duplicates.txt")),
|
|
250
|
+
"trend": _read_json(out_dir, "trend.json", {"samples": [], "files": {}}),
|
|
251
|
+
"backtest": load_report(os.path.join(out_dir, "backtest"), nested=False)
|
|
252
|
+
if nested and os.path.isfile(os.path.join(out_dir, "backtest", "meta.json")) else None,
|
|
253
|
+
}
|
gitmole/loss.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Knowledge loss: who has stopped committing, and how much of the code is theirs.
|
|
2
|
+
|
|
3
|
+
"Gone" is measured against the repository's last commit, not today's date, so a clone that was
|
|
4
|
+
last fetched a year ago does not mark everyone as gone. It is also measured over the whole
|
|
5
|
+
history: a window (--since) that hides someone's last commit must not turn them into a person
|
|
6
|
+
who never existed."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from . import identity, knowledge, maat
|
|
10
|
+
|
|
11
|
+
DEFAULT_MONTHS = 12
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cutoff(report: dict, months: int = DEFAULT_MONTHS):
|
|
15
|
+
last = report["meta"].get("last_date") or ""
|
|
16
|
+
return maat.months_before(last, months) if last else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def gone(report: dict, months: int = DEFAULT_MONTHS) -> list:
|
|
20
|
+
"""People whose last commit is before the cut-off, by name. Bots are never people."""
|
|
21
|
+
cut = cutoff(report, months)
|
|
22
|
+
act = report.get("activity") or {}
|
|
23
|
+
authors = act.get("authors_all") or act.get("authors") or {} # authors_all is absent in older output directories
|
|
24
|
+
if not cut or not authors:
|
|
25
|
+
return []
|
|
26
|
+
bots = {b["name"] for b in report["meta"].get("bots") or []}
|
|
27
|
+
out = [{"name": name, "last": a.get("last", "")} for name, a in authors.items()
|
|
28
|
+
if name not in bots and not identity.is_bot(name) and a.get("last", "") < cut]
|
|
29
|
+
return sorted(out, key=lambda g: g["name"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def surviving(report: dict, gone_names) -> tuple:
|
|
33
|
+
"""(surviving lines written by gone people, all surviving lines); (0, 0) without a blame pass."""
|
|
34
|
+
shares = report.get("theseus_authors") or {}
|
|
35
|
+
return sum(n for name, n in shares.items() if name in gone_names), sum(shares.values())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def areas(rows: list, gone_names) -> list:
|
|
39
|
+
"""knowledge.areas over the given ownership rows, each row with `lost` lines and `lost_share`."""
|
|
40
|
+
out = []
|
|
41
|
+
for a in knowledge.areas(rows):
|
|
42
|
+
lost = sum(n for name, n in a["owners"] if name in gone_names)
|
|
43
|
+
out.append({**a, "lost": lost, "lost_share": lost / a["lines"] if a["lines"] else 0.0})
|
|
44
|
+
return out
|