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/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)