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/maat.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Change analysis over a git log export, in the layout code-maat produced.
|
|
3
|
+
|
|
4
|
+
Standalone on purpose: gitmole runs it as a pipeline step with
|
|
5
|
+
`python3 maat.py LOG OUT_DIR [--aliases META_JSON]` and it must not need the
|
|
6
|
+
package on sys.path. Input is `git log --all --numstat --date=short
|
|
7
|
+
--pretty=format:--%h--%ad--%aN --no-renames`.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import itertools
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from collections import Counter, defaultdict
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from . import filetypes
|
|
23
|
+
except ImportError: # run as a script: the package directory is sys.path[0]
|
|
24
|
+
import filetypes
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_log(text: str, aliases: dict = None, types=None) -> list:
|
|
28
|
+
"""[{hash, date, time, author, files: [(path, added, deleted)]}], binary files count as 0/0.
|
|
29
|
+
`types` restricts the file entries (None = keep everything); commits are always kept."""
|
|
30
|
+
aliases = aliases or {}
|
|
31
|
+
commits, current = [], None
|
|
32
|
+
# split on newlines only: str.splitlines also breaks on \r, form feed and Unicode separators,
|
|
33
|
+
# any of which can appear inside a commit subject
|
|
34
|
+
for line in text.split("\n"):
|
|
35
|
+
if line.startswith("--"):
|
|
36
|
+
parts = line.split("--", 4) # subject is last, so dashes inside it survive
|
|
37
|
+
_, h, when, author = parts[:4]
|
|
38
|
+
subject = parts[4] if len(parts) > 4 else ""
|
|
39
|
+
current = {"hash": h, "date": when[:10], "time": when, "author": aliases.get(author, author), "subject": subject, "files": []}
|
|
40
|
+
commits.append(current)
|
|
41
|
+
elif line.strip() and current is not None:
|
|
42
|
+
added, deleted, path = line.split("\t", 2)
|
|
43
|
+
path = filetypes.unquote(path)
|
|
44
|
+
if not filetypes.matches(path, types):
|
|
45
|
+
continue
|
|
46
|
+
current["files"].append((path, int(added) if added.isdigit() else 0, int(deleted) if deleted.isdigit() else 0))
|
|
47
|
+
return commits
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_FIX_CONVENTIONAL = re.compile(r"^(fix|hotfix|bugfix)(\([^)]*\))?!?:", re.I)
|
|
51
|
+
_FIX_WORDS = re.compile(r"\b(fix|fixes|fixed|fixing|bugfix|hotfix|bug|bugs|regression|crash|crashes)\b", re.I)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_fix(subject: str) -> bool:
|
|
55
|
+
"""Does the commit subject describe a bug fix? Conventional `fix:` or plain fix/bug words."""
|
|
56
|
+
return bool(_FIX_CONVENTIONAL.match(subject or "") or _FIX_WORDS.search(subject or ""))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_revert(subject: str) -> bool:
|
|
60
|
+
"""git's own revert subject: `Revert "..."`. Case-sensitive, the quote is not required."""
|
|
61
|
+
return (subject or "").startswith("Revert ")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _revs(commits) -> Counter:
|
|
65
|
+
return Counter(path for c in commits for path, _, _ in c["files"])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def revisions(commits: list) -> list:
|
|
69
|
+
return [{"entity": e, "n-revs": n} for e, n in sorted(_revs(commits).items(), key=lambda kv: (-kv[1], kv[0]))]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def coupling(commits: list, min_shared: int = 5, min_degree: int = 30, max_changeset: int = 30) -> list:
|
|
73
|
+
revs = _revs(commits)
|
|
74
|
+
shared = Counter()
|
|
75
|
+
for c in commits:
|
|
76
|
+
paths = sorted({p for p, _, _ in c["files"]})
|
|
77
|
+
if len(paths) > max_changeset:
|
|
78
|
+
continue
|
|
79
|
+
for a, b in itertools.combinations(paths, 2):
|
|
80
|
+
shared[(a, b)] += 1
|
|
81
|
+
rows = []
|
|
82
|
+
for (a, b), n in shared.items():
|
|
83
|
+
if n < min_shared:
|
|
84
|
+
continue
|
|
85
|
+
avg = (revs[a] + revs[b]) / 2
|
|
86
|
+
degree = int(math.floor(100 * n / avg + 0.5))
|
|
87
|
+
if degree < min_degree:
|
|
88
|
+
continue
|
|
89
|
+
rows.append({"entity": a, "coupled": b, "degree": degree, "average-revs": int(math.floor(avg + 0.5))})
|
|
90
|
+
rows.sort(key=lambda r: (-r["degree"], -r["average-revs"], r["entity"], r["coupled"]))
|
|
91
|
+
return rows
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def authors(commits: list) -> list:
|
|
95
|
+
who, revs = defaultdict(set), _revs(commits)
|
|
96
|
+
for c in commits:
|
|
97
|
+
for p, _, _ in c["files"]:
|
|
98
|
+
who[p].add(c["author"])
|
|
99
|
+
rows = [{"entity": e, "n-authors": len(s), "n-revs": revs[e]} for e, s in who.items()]
|
|
100
|
+
rows.sort(key=lambda r: (-r["n-authors"], -r["n-revs"], r["entity"]))
|
|
101
|
+
return rows
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _months_between(earlier: str, later: str) -> int:
|
|
105
|
+
a, b = dt.date.fromisoformat(earlier), dt.date.fromisoformat(later)
|
|
106
|
+
return max(0, (b.year - a.year) * 12 + (b.month - a.month) - (1 if b.day < a.day else 0))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def months_before(date: str, months: int) -> str:
|
|
110
|
+
"""The ISO date `months` whole months before `date`, day clamped to the month's length."""
|
|
111
|
+
import calendar
|
|
112
|
+
d = dt.date.fromisoformat(date)
|
|
113
|
+
y, m = d.year, d.month - months
|
|
114
|
+
while m <= 0:
|
|
115
|
+
y, m = y - 1, m + 12
|
|
116
|
+
return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1])).isoformat()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def age(commits: list, now: str = None) -> list:
|
|
120
|
+
now = now or dt.date.today().isoformat()
|
|
121
|
+
last = {}
|
|
122
|
+
for c in commits:
|
|
123
|
+
for p, _, _ in c["files"]:
|
|
124
|
+
last[p] = max(last.get(p, ""), c["date"])
|
|
125
|
+
rows = [{"entity": e, "age-months": _months_between(d, now)} for e, d in last.items()]
|
|
126
|
+
rows.sort(key=lambda r: (r["age-months"], r["entity"]))
|
|
127
|
+
return rows
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
RECENT_MONTHS = 6
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def fixes(commits: list, now: str = None) -> list:
|
|
134
|
+
"""Per entity: how many fix commits touched it, the last one, and how many in the recent window."""
|
|
135
|
+
now = now or dt.date.today().isoformat()
|
|
136
|
+
total, last, recent = Counter(), {}, Counter()
|
|
137
|
+
for c in commits:
|
|
138
|
+
if not is_fix(c.get("subject", "")):
|
|
139
|
+
continue
|
|
140
|
+
fresh = _months_between(c["date"], now) < RECENT_MONTHS
|
|
141
|
+
for p, _, _ in c["files"]:
|
|
142
|
+
total[p] += 1
|
|
143
|
+
last[p] = max(last.get(p, ""), c["date"])
|
|
144
|
+
if fresh:
|
|
145
|
+
recent[p] += 1
|
|
146
|
+
rows = [{"entity": p, "n-fixes": n, "last-fix": last[p], "recent-fixes": recent[p]} for p, n in total.items()]
|
|
147
|
+
rows.sort(key=lambda r: (-r["recent-fixes"], -r["n-fixes"], r["entity"]))
|
|
148
|
+
return rows
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def entity_ownership(commits: list) -> list:
|
|
152
|
+
added, deleted = Counter(), Counter()
|
|
153
|
+
for c in commits:
|
|
154
|
+
for p, a, d in c["files"]:
|
|
155
|
+
added[(p, c["author"])] += a
|
|
156
|
+
deleted[(p, c["author"])] += d
|
|
157
|
+
rows = [{"entity": p, "author": who, "added": added[(p, who)], "deleted": deleted[(p, who)]} for (p, who) in added]
|
|
158
|
+
rows.sort(key=lambda r: (r["entity"], r["author"]))
|
|
159
|
+
return rows
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def author_totals(commits: list) -> dict:
|
|
163
|
+
"""Per author: commits, lines added and deleted, and the first and last date they committed."""
|
|
164
|
+
out = {}
|
|
165
|
+
for c in commits:
|
|
166
|
+
a = out.setdefault(c["author"], {"commits": 0, "added": 0, "deleted": 0, "first": c["date"], "last": c["date"]})
|
|
167
|
+
a["commits"] += 1
|
|
168
|
+
a["added"] += sum(x for _, x, _ in c["files"])
|
|
169
|
+
a["deleted"] += sum(x for _, _, x in c["files"])
|
|
170
|
+
a["first"], a["last"] = min(a["first"], c["date"]), max(a["last"], c["date"])
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def activity(commits: list) -> dict:
|
|
175
|
+
"""Commits by weekday (Mon=0) and hour, by month, and per-author totals."""
|
|
176
|
+
by_weekday, by_hour, by_month, net_by_year = [0] * 7, [0] * 24, Counter(), Counter()
|
|
177
|
+
timeline, fix_commits = defaultdict(Counter), 0
|
|
178
|
+
revert_commits, reverted = 0, Counter()
|
|
179
|
+
for c in commits:
|
|
180
|
+
when = c.get("time") or c["date"]
|
|
181
|
+
try:
|
|
182
|
+
# git >= 2.45 writes UTC as a trailing Z, which fromisoformat rejects before Python 3.11
|
|
183
|
+
stamp = dt.datetime.fromisoformat(when[:-1] + "+00:00" if when.endswith("Z") else when)
|
|
184
|
+
except ValueError:
|
|
185
|
+
stamp = None
|
|
186
|
+
day = stamp.date() if stamp else dt.date.fromisoformat(c["date"])
|
|
187
|
+
by_weekday[day.weekday()] += 1
|
|
188
|
+
if stamp and len(when) > 10:
|
|
189
|
+
by_hour[stamp.hour] += 1
|
|
190
|
+
by_month[c["date"][:7]] += 1
|
|
191
|
+
is_rev = is_revert(c.get("subject", ""))
|
|
192
|
+
net = 0
|
|
193
|
+
for p, a, d in c["files"]:
|
|
194
|
+
net += a - d
|
|
195
|
+
if is_rev:
|
|
196
|
+
reverted[p] += 1
|
|
197
|
+
net_by_year[c["date"][:4]] += net
|
|
198
|
+
timeline[c["author"]][c["date"][:7]] += 1
|
|
199
|
+
fix_commits += is_fix(c.get("subject", ""))
|
|
200
|
+
if is_rev:
|
|
201
|
+
revert_commits += 1
|
|
202
|
+
return {"by_weekday": by_weekday, "by_hour": by_hour, "by_month": dict(sorted(by_month.items())),
|
|
203
|
+
"net_by_year": dict(sorted(net_by_year.items())), "authors": author_totals(commits),
|
|
204
|
+
"timeline": {a: dict(sorted(m.items())) for a, m in timeline.items()}, "fix_commits": fix_commits,
|
|
205
|
+
"revert_commits": revert_commits,
|
|
206
|
+
"reverted": dict(sorted(reverted.items(), key=lambda kv: (-kv[1], kv[0])))}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
ANALYSES = {
|
|
210
|
+
"revisions": (revisions, ["entity", "n-revs"]),
|
|
211
|
+
"coupling": (coupling, ["entity", "coupled", "degree", "average-revs"]),
|
|
212
|
+
"authors": (authors, ["entity", "n-authors", "n-revs"]),
|
|
213
|
+
"age": (age, ["entity", "age-months"]),
|
|
214
|
+
"entity-ownership": (entity_ownership, ["entity", "author", "added", "deleted"]),
|
|
215
|
+
"fixes": (fixes, ["entity", "n-fixes", "last-fix", "recent-fixes"]),
|
|
216
|
+
}
|
|
217
|
+
NEEDS_NOW = {"age", "fixes"}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def aliases_from_meta(path: str) -> dict:
|
|
221
|
+
with open(path) as fh:
|
|
222
|
+
meta = json.load(fh)
|
|
223
|
+
if "aliases" in meta:
|
|
224
|
+
return dict(meta["aliases"])
|
|
225
|
+
out = {}
|
|
226
|
+
for ident in meta.get("identities", []):
|
|
227
|
+
for a in ident.get("aliases", []):
|
|
228
|
+
out[a["name"]] = ident["name"]
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def in_window(commits: list, since: str = None, until: str = None) -> list:
|
|
233
|
+
"""Commits authored on or after `since` and before `until` (YYYY-MM-DD); all of them when both are None."""
|
|
234
|
+
return [c for c in commits if (not since or c["date"] >= since) and (not until or c["date"] < until)]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def validate_now(value: str) -> str:
|
|
238
|
+
"""A reference date must be exactly YYYY-MM-DD."""
|
|
239
|
+
if len(value) != 10 or dt.date.fromisoformat(value).isoformat() != value:
|
|
240
|
+
raise ValueError(f"reference date must be YYYY-MM-DD, got {value!r}")
|
|
241
|
+
return value
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def write_all(log_path: str, out_dir: str, aliases_path: str = None, types=filetypes.DEFAULT, now: str = None, since: str = None, until: str = None) -> None:
|
|
245
|
+
"""`now` (YYYY-MM-DD) is the reference date for file ages; default today. `since` and `until` bound every
|
|
246
|
+
analysis except file ages and activity.json's `authors_all`, which describe the whole history."""
|
|
247
|
+
# newline="": keep a \r inside a subject as-is instead of turning it into a line break
|
|
248
|
+
with open(log_path, encoding="utf-8", errors="replace", newline="") as fh:
|
|
249
|
+
commits = parse_log(fh.read(), aliases_from_meta(aliases_path) if aliases_path else None, types)
|
|
250
|
+
windowed = in_window(commits, since, until)
|
|
251
|
+
for name, (fn, header) in ANALYSES.items():
|
|
252
|
+
source = commits if name == "age" else windowed # ages describe the whole history
|
|
253
|
+
rows = fn(source, now=now) if name in NEEDS_NOW else fn(source)
|
|
254
|
+
with open(os.path.join(out_dir, f"maat-{name}.csv"), "w", newline="", encoding="utf-8") as fh:
|
|
255
|
+
w = csv.DictWriter(fh, fieldnames=header)
|
|
256
|
+
w.writeheader()
|
|
257
|
+
w.writerows(rows)
|
|
258
|
+
act = activity(windowed)
|
|
259
|
+
# knowledge loss is a whole-history question, so it reads authors_all, not the windowed table
|
|
260
|
+
act["authors_all"] = author_totals(commits)
|
|
261
|
+
act["window"] = since
|
|
262
|
+
act["until"] = until
|
|
263
|
+
with open(os.path.join(out_dir, "activity.json"), "w", encoding="utf-8") as fh:
|
|
264
|
+
json.dump(act, fh)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
args = sys.argv[1:]
|
|
269
|
+
aliases, types, now, since, until = None, filetypes.DEFAULT, None, None, None
|
|
270
|
+
while "--since" in args:
|
|
271
|
+
i = args.index("--since")
|
|
272
|
+
try:
|
|
273
|
+
since = validate_now(args[i + 1])
|
|
274
|
+
except (ValueError, IndexError) as e:
|
|
275
|
+
sys.exit(f"maat.py: {e}")
|
|
276
|
+
del args[i:i + 2]
|
|
277
|
+
while "--until" in args:
|
|
278
|
+
i = args.index("--until")
|
|
279
|
+
try:
|
|
280
|
+
until = validate_now(args[i + 1])
|
|
281
|
+
except (ValueError, IndexError) as e:
|
|
282
|
+
sys.exit(f"maat.py: {e}")
|
|
283
|
+
del args[i:i + 2]
|
|
284
|
+
while "--now" in args:
|
|
285
|
+
i = args.index("--now")
|
|
286
|
+
try:
|
|
287
|
+
now = validate_now(args[i + 1])
|
|
288
|
+
except (ValueError, IndexError) as e:
|
|
289
|
+
sys.exit(f"maat.py: {e}")
|
|
290
|
+
del args[i:i + 2]
|
|
291
|
+
while "--types" in args:
|
|
292
|
+
i = args.index("--types"); types = filetypes.parse(args[i + 1]); del args[i:i + 2]
|
|
293
|
+
if "--aliases" in args:
|
|
294
|
+
i = args.index("--aliases")
|
|
295
|
+
aliases = args[i + 1]
|
|
296
|
+
del args[i:i + 2]
|
|
297
|
+
if len(args) != 2:
|
|
298
|
+
sys.exit("usage: maat.py LOG OUT_DIR [--aliases META_JSON] [--types LIST|all] [--now YYYY-MM-DD] [--since YYYY-MM-DD] [--until YYYY-MM-DD]")
|
|
299
|
+
write_all(args[0], args[1], aliases, types, now, since, until)
|