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/run.py ADDED
@@ -0,0 +1,407 @@
1
+ """Resolve the target, plan the tool invocations, and run them concurrently."""
2
+ from __future__ import annotations
3
+
4
+ import calendar
5
+ import datetime as dt
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ import re
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ import threading
14
+ from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
15
+
16
+ from . import blame, filetypes, identity
17
+
18
+ MAAT_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), "maat.py")
19
+ BLAME_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), "blame.py")
20
+ FUNCTIONS_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), "functions.py")
21
+ LEAKS_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), "leaks.py")
22
+ # lizard's duplicate finder keeps a hash node per token, and every pool worker grows to 1.5-2 GB on a
23
+ # large repo; the blame default (cores minus two) exhausted a 16 GB machine. Two workers is the ceiling
24
+ # when it runs. Without it lizard is fast (well under a second per thousand files) and uses the blame workers.
25
+ FUNCTIONS_MAX_PROCS = 2
26
+
27
+ MONTH = 30 * 24 * 3600 # git-of-theseus sampling interval in seconds
28
+
29
+ # Data-like files that inflate git-of-theseus without saying anything about code age.
30
+ DATA_IGNORES = ["*.csv", "*.json", "*.lock", "*.min.js", "*.min.css", "*.svg", "*.map",
31
+ "vendor/**", "node_modules/**", "third_party/**", "dist/**", "build/**"]
32
+
33
+ _ORG = re.compile(r"^[\w.-]+/\*$")
34
+ _OWNER_REPO = re.compile(r"^[\w.-]+/[\w.-]+$")
35
+ _URL = re.compile(r"^(https?://|git@|ssh://)")
36
+
37
+
38
+ def classify_target(target: str) -> tuple:
39
+ if os.path.isdir(target):
40
+ return ("path", os.path.abspath(target))
41
+ if _ORG.match(target):
42
+ return ("org", target[:-2])
43
+ if _URL.match(target) or _OWNER_REPO.match(target):
44
+ return ("remote", target)
45
+ raise ValueError(f"{target!r} is neither a directory, owner/repo, nor a git URL")
46
+
47
+
48
+ def repo_name(target: str) -> str:
49
+ tail = target.rstrip("/").rsplit("/", 1)[-1]
50
+ return tail[:-4] if tail.endswith(".git") else tail
51
+
52
+
53
+ def output_dir(kind: str, repo_dir: str, explicit, cwd: str = None) -> str:
54
+ if explicit:
55
+ return os.path.abspath(explicit)
56
+ name = f"analysis-{repo_name(repo_dir)}"
57
+ base = os.path.dirname(repo_dir) if kind == "path" else (cwd or os.getcwd())
58
+ return os.path.join(base, name)
59
+
60
+
61
+ class GhError(RuntimeError):
62
+ """gh failed; the message is its stderr."""
63
+
64
+
65
+ def _gh(argv: list) -> str:
66
+ return subprocess.run(argv, check=True, capture_output=True, text=True).stdout
67
+
68
+
69
+ def _wrap(fn, argv):
70
+ try:
71
+ return fn(argv)
72
+ except subprocess.CalledProcessError as e:
73
+ raise GhError((e.stderr or "").strip() or f"{' '.join(argv)} exited {e.returncode}") from None
74
+ except FileNotFoundError:
75
+ raise GhError("gh is not installed or not on PATH") from None
76
+
77
+
78
+ def list_repos(owner: str, lister=_gh) -> list:
79
+ """Names of the owner's non-archived repositories via gh's REST calls. Raises GhError.
80
+
81
+ Your own account lists private repos too; an organisation uses the org endpoint
82
+ (private repos included where the token allows); anyone else gets public repos."""
83
+ me = _wrap(lister, ["gh", "api", "user", "--jq", ".login"]).strip()
84
+ if me == owner:
85
+ path = "user/repos?affiliation=owner&per_page=100"
86
+ else:
87
+ try:
88
+ _wrap(lister, ["gh", "api", f"orgs/{owner}", "--jq", ".login"])
89
+ path = f"orgs/{owner}/repos?per_page=100"
90
+ except GhError:
91
+ path = f"users/{owner}/repos?per_page=100"
92
+ out = _wrap(lister, ["gh", "api", "--paginate", path, "--jq", ".[] | select(.archived | not) | .name"])
93
+ return sorted(set(out.split()))
94
+
95
+
96
+ def clone(target: str, dest_parent: str, runner=_gh) -> str:
97
+ """Clone a remote target with gh (so private repos use the existing auth). Raises GhError."""
98
+ dest = os.path.join(dest_parent, repo_name(target))
99
+ _wrap(runner, ["gh", "repo", "clone", target, dest, "--", "--quiet"])
100
+ return dest
101
+
102
+
103
+ def env_path() -> str:
104
+ """PATH with pip's user bin dirs added."""
105
+ parts = []
106
+ lib = os.path.expanduser("~/Library/Python")
107
+ if os.path.isdir(lib):
108
+ parts += [os.path.join(lib, v, "bin") for v in sorted(os.listdir(lib), reverse=True)]
109
+ return os.pathsep.join(parts + [os.environ.get("PATH", "")])
110
+
111
+
112
+ REQUIRED_TOOLS = ["scc", "git-sizer", "gitleaks"]
113
+ PLOT_TOOLS = ["git-of-theseus-analyze"]
114
+
115
+
116
+ def has_tool(name: str, path: str = None) -> bool:
117
+ path = env_path() if path is None else path
118
+ return any(os.access(os.path.join(d, name), os.X_OK) for d in path.split(os.pathsep) if d)
119
+
120
+
121
+ def missing_tools(plots: bool = False, path: str = None) -> list:
122
+ path = env_path() if path is None else path
123
+ wanted = REQUIRED_TOOLS + (PLOT_TOOLS if plots else [])
124
+ return [t for t in wanted if not has_tool(t, path)]
125
+
126
+
127
+ def has_lizard(finder=importlib.util.find_spec) -> bool:
128
+ """lizard is a Python module run with this interpreter, so PATH says nothing about it."""
129
+ return finder("lizard") is not None
130
+
131
+
132
+ # Everything a run writes besides meta.json and run.log. Removed before each run so a reused
133
+ # --out directory never shows a previous run's data as this run's (a step skipped or killed
134
+ # this time would otherwise leave last time's file in place).
135
+ OUTPUTS = ["size.json", "repo-health.txt", "secrets.json", "log.txt", "activity.json", "functions.csv", "duplicates.txt",
136
+ "theseus/cohorts.json", "theseus/authors.json", "theseus/survival.json", "code-age.png", "survival.png", "trend.json"]
137
+ OUTPUT_GLOBS = ["maat-*.csv"]
138
+ # directories a run writes: the backtest sub-report, and the temporary checkouts the trend and
139
+ # backtest steps make under the output directory (a SIGKILL leaves those behind).
140
+ OUTPUT_DIR_GLOBS = ["backtest", ".backtest-tree-*", ".trend-*"]
141
+
142
+
143
+ def clear_outputs(out_dir: str) -> None:
144
+ import glob
145
+ import shutil
146
+ paths = [os.path.join(out_dir, n) for n in OUTPUTS]
147
+ for g in OUTPUT_GLOBS:
148
+ paths += glob.glob(os.path.join(out_dir, g))
149
+ for g in OUTPUT_DIR_GLOBS:
150
+ paths += glob.glob(os.path.join(out_dir, g))
151
+ for path in paths:
152
+ if os.path.isdir(path):
153
+ shutil.rmtree(path, ignore_errors=True)
154
+ elif os.path.isfile(path):
155
+ os.remove(path)
156
+
157
+
158
+ def plan(repo_dir: str, out_dir: str, branch: str = "HEAD", age: bool = True, plots: bool = False,
159
+ procs: int = None, interval: int = MONTH, ignore=(), types: str = None, now: str = None, since: str = None,
160
+ lizard: bool = False, duplicates: bool = False, trend: bool = True, samples: int = 12, backtest: str = None) -> list:
161
+ o = lambda name: os.path.join(out_dir, name) # noqa: E731
162
+ log = o("log.txt")
163
+ ignores = [x for pattern in ignore for x in ("--ignore", pattern)]
164
+ type_args = ["--types", types] if types else []
165
+ blame_argv = [sys.executable, BLAME_SCRIPT, repo_dir, out_dir, "--procs", str(procs or blame.default_procs()), *ignores, *type_args, "--aliases", o("meta.json")]
166
+ theseus_argv = ["git-of-theseus-analyze", ".", "--branch", branch, "--outdir", o("theseus"),
167
+ "--procs", str(procs or os.cpu_count() or 2), "--interval", str(interval), *ignores]
168
+ steps = [
169
+ {"name": "scc", "argv": ["scc", "--by-file", "--format", "json"], "stdout": o("size.json"), "deps": []},
170
+ {"name": "git-sizer", "argv": ["git-sizer", "--verbose"], "stdout": o("repo-health.txt"), "deps": []},
171
+ {"name": "gitleaks", "argv": [sys.executable, LEAKS_SCRIPT, o("secrets.json")], "stdout": None, "deps": []}, # hashes the values before anything is written
172
+ {"name": "git-log", "argv": [*filetypes.GIT, "log", "--all", "--use-mailmap", "--numstat", "--date=iso-strict", "--pretty=format:--%h--%ad--%aN--%s", "--no-renames"], "stdout": log, "deps": []},
173
+ {"name": "change analysis", "argv": [sys.executable, MAAT_SCRIPT, log, out_dir, *type_args, *(["--now", now] if now else []), *(["--since", since] if since else []), "--aliases", o("meta.json")], "stdout": None, "deps": ["git-log"]},
174
+ ]
175
+ if lizard:
176
+ workers = procs or blame.default_procs()
177
+ if duplicates:
178
+ workers = min(workers, FUNCTIONS_MAX_PROCS)
179
+ steps.append({"name": "functions", "argv": [sys.executable, FUNCTIONS_SCRIPT, repo_dir, out_dir, "--procs", str(workers), *ignores, *type_args,
180
+ *(["--duplicates"] if duplicates else [])],
181
+ "stdout": None, "deps": []})
182
+ if trend:
183
+ steps.append({"name": "trend", "argv": [sys.executable, "-m", "gitmole.trend", out_dir, "--samples", str(samples)],
184
+ "stdout": None, "deps": ["scc", "change analysis"]})
185
+ if backtest:
186
+ steps.append({"name": "backtest", "argv": [sys.executable, "-m", "gitmole.backtest", out_dir, "--until", backtest],
187
+ "stdout": None, "deps": ["git-log", "change analysis"]})
188
+ if age:
189
+ steps.append({"name": "code age", "argv": blame_argv, "stdout": None, "deps": []})
190
+ if plots:
191
+ steps += [
192
+ {"name": "git-of-theseus", "argv": theseus_argv, "stdout": None, "deps": ["code age"] if age else []},
193
+ {"name": "theseus stack plot", "argv": ["git-of-theseus-stack-plot", o("theseus/cohorts.json"), "--outfile", o("code-age.png")], "stdout": None, "deps": ["git-of-theseus"]},
194
+ {"name": "theseus survival plot", "argv": ["git-of-theseus-survival-plot", o("theseus/survival.json"), "--outfile", o("survival.png")], "stdout": None, "deps": ["git-of-theseus"]},
195
+ ]
196
+ return steps
197
+
198
+
199
+ _RELATIVE = re.compile(r"^(\d+)([ymd])$")
200
+
201
+
202
+ def parse_since(spec: str, today: str) -> str:
203
+ """'2y' | '18m' | '90d' | 'YYYY-MM-DD' -> 'YYYY-MM-DD', relative to `today`. Raises ValueError."""
204
+ from . import maat
205
+
206
+ hint = "--since wants 2y, 18m, 90d or YYYY-MM-DD"
207
+ spec = (spec or "").strip().lower()
208
+ m = _RELATIVE.match(spec)
209
+ try:
210
+ if not m:
211
+ date = dt.date.fromisoformat(maat.validate_now(spec))
212
+ else:
213
+ n, unit = int(m.group(1)), m.group(2)
214
+ base = dt.date.fromisoformat(today)
215
+ if unit == "d":
216
+ date = base - dt.timedelta(days=n)
217
+ else:
218
+ months = n * 12 if unit == "y" else n
219
+ y, mo = base.year, base.month - months
220
+ while mo <= 0:
221
+ y, mo = y - 1, mo + 12
222
+ date = dt.date(y, mo, min(base.day, calendar.monthrange(y, mo)[1]))
223
+ except (ValueError, OverflowError):
224
+ raise ValueError(f"{hint}, got {spec!r}") from None
225
+ if date.year < 1970:
226
+ raise ValueError(f"{hint}; git cannot represent dates before 1970, got {spec!r}")
227
+ return date.isoformat()
228
+
229
+
230
+ class Control:
231
+ """Shared cancellation state: tracks running process groups so Ctrl-C can kill them all."""
232
+
233
+ def __init__(self):
234
+ self.cancelled = threading.Event()
235
+ self._procs = set()
236
+ self._lock = threading.Lock()
237
+
238
+ def register(self, proc):
239
+ with self._lock:
240
+ self._procs.add(proc)
241
+
242
+ def unregister(self, proc):
243
+ with self._lock:
244
+ self._procs.discard(proc)
245
+
246
+ def cancel(self):
247
+ self.cancelled.set()
248
+ with self._lock:
249
+ procs = list(self._procs)
250
+ for proc in procs:
251
+ _killpg(proc)
252
+
253
+
254
+ def _killpg(proc):
255
+ try:
256
+ os.killpg(proc.pid, signal.SIGKILL)
257
+ except ProcessLookupError:
258
+ pass
259
+
260
+
261
+ def _run_step(argv, cwd, env, stdout, stderr, timeout, control: Control = None):
262
+ """Run one command in its own process group so a timeout or Ctrl-C can kill its children too.
263
+
264
+ stdin is /dev/null: these tools never need input, and letting them inherit an
265
+ interactive terminal as a new session leader corrupts the parent's tty (EIO)."""
266
+ proc = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.DEVNULL,
267
+ stdout=stdout, stderr=stderr, start_new_session=True)
268
+ if control:
269
+ control.register(proc)
270
+ try:
271
+ rc = proc.wait(timeout=timeout)
272
+ return "cancelled" if control and control.cancelled.is_set() else rc
273
+ except subprocess.TimeoutExpired:
274
+ _killpg(proc)
275
+ proc.wait()
276
+ return "timeout"
277
+ finally:
278
+ if control:
279
+ control.unregister(proc)
280
+
281
+
282
+ def execute(steps: list, log_path: str, cwd: str = None, workers: int = 6, on_start=None, on_done=None,
283
+ timeout: float = None, control: Control = None) -> dict:
284
+ """Run steps concurrently, honouring deps. Returns {name: returncode | 'skipped' | 'timeout' | 'cancelled'}."""
285
+ results = {}
286
+ lock = threading.Lock()
287
+ package_parent = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
288
+ env = dict(os.environ, PATH=env_path(),
289
+ PYTHONPATH=os.pathsep.join([package_parent] + [p for p in [os.environ.get("PYTHONPATH", "")] if p]))
290
+ pending = {s["name"]: s for s in steps}
291
+
292
+ def run_one(step):
293
+ if on_start:
294
+ on_start(step["name"])
295
+ with open(log_path, "a") as log:
296
+ log.write(f"\n==> {step['name']}: {' '.join(step['argv'])}\n")
297
+ log.flush()
298
+ out = open(step["stdout"], "w") if step["stdout"] else log
299
+ try:
300
+ rc = _run_step(step["argv"], cwd, env, out, log, timeout, control)
301
+ if rc == "timeout":
302
+ log.write(f"==> {step['name']}: killed after {timeout}s timeout\n")
303
+ finally:
304
+ if step["stdout"]:
305
+ out.close()
306
+ return step["name"], rc
307
+
308
+ with ThreadPoolExecutor(max_workers=workers) as pool:
309
+ futures = set()
310
+ while pending or futures:
311
+ for name in list(pending):
312
+ step = pending[name]
313
+ if control and control.cancelled.is_set():
314
+ results[name] = "cancelled"
315
+ del pending[name]
316
+ if on_done:
317
+ on_done(name, "cancelled")
318
+ elif any(results.get(d) not in (None, 0) for d in step["deps"]):
319
+ results[name] = "skipped"
320
+ del pending[name]
321
+ if on_done:
322
+ on_done(name, "skipped")
323
+ elif all(results.get(d) == 0 for d in step["deps"]):
324
+ futures.add(pool.submit(run_one, step))
325
+ del pending[name]
326
+ if not futures:
327
+ continue
328
+ done, futures = wait(futures, return_when=FIRST_COMPLETED)
329
+ for f in done:
330
+ name, rc = f.result()
331
+ with lock:
332
+ results[name] = rc
333
+ if on_done:
334
+ on_done(name, rc)
335
+ return results
336
+
337
+
338
+ def _git(repo_dir: str, *args) -> str:
339
+ return subprocess.run(["git", *args], cwd=repo_dir, check=True, capture_output=True, text=True).stdout
340
+
341
+
342
+ def estimate_blames(repo_dir: str, interval: int = MONTH, ignore=(), sample: int = 25, types=filetypes.DEFAULT) -> dict:
343
+ """Cost of the blame passes: a timed projection for the HEAD pass (seconds) and
344
+ tracked files times sampled commits for git-of-theseus (blames)."""
345
+ files = len(_git(repo_dir, "ls-files").splitlines())
346
+ times = [int(t) for t in _git(repo_dir, "log", "--format=%ct").split()]
347
+ span = (max(times) - min(times)) if times else 0
348
+ samples = min(len(times), span // interval + 1) if times else 0
349
+ projection = blame.estimate(repo_dir, ignore=ignore, sample=sample, types=types)
350
+ return {"files": files, "samples": samples, "blames": files * samples,
351
+ "seconds": projection["seconds"], "code_files": projection["files"]}
352
+
353
+
354
+ def collect_meta(repo_dir: str, since: str = None) -> dict:
355
+ """Repository facts from git. The window (author date >= since) bounds the commit count, the
356
+ date range and the identity table; aliases are merged over the whole history so blame and
357
+ ownership keep merging people who have no commits in the window, and `first_date_all` keeps the
358
+ date of the first commit of all so the backtest can still measure the whole history. Bots
359
+ (renovate, dependabot, GitHub Actions and anything named *[bot]) are counted apart under
360
+ "bots", not as identities."""
361
+ from collections import Counter
362
+
363
+ from .load import parse_authors_log
364
+
365
+ lines = _git(repo_dir, "log", "--all", "--use-mailmap", "--format=%ad\t%aN\t%aE", "--date=short").splitlines()
366
+ all_rows = [l.split("\t", 2) for l in lines if l.count("\t") == 2]
367
+ rows = [r for r in all_rows if not identity.is_bot(r[1], r[2])]
368
+ all_windowed = [r for r in all_rows if not since or r[0] >= since]
369
+ windowed = [r for r in all_windowed if not identity.is_bot(r[1], r[2])]
370
+ dates = [r[0] for r in all_windowed]
371
+ all_dates = [r[0] for r in all_rows]
372
+ bots = Counter(n for _, n, e in all_windowed if identity.is_bot(n, e))
373
+ all_identities = identity.merge(parse_authors_log("\n".join(f"{n}\t{e}" for _, n, e in rows)))
374
+ meta = {
375
+ "name": repo_name(repo_dir),
376
+ "path": repo_dir,
377
+ "branch": _git(repo_dir, "rev-parse", "--abbrev-ref", "HEAD").strip(),
378
+ "commits": len(dates),
379
+ "first_date": min(dates) if dates else "",
380
+ "first_date_all": min(all_dates) if all_dates else "", # unwindowed: the backtest asks how long the history is
381
+ "last_date": max(dates) if dates else "",
382
+ "identities": identity.merge(parse_authors_log("\n".join(f"{n}\t{e}" for _, n, e in windowed))),
383
+ "bots": [{"name": n, "commits": c} for n, c in sorted(bots.items(), key=lambda kv: (-kv[1], kv[0]))],
384
+ "aliases": {a["name"]: i["name"] for i in all_identities for a in i.get("aliases", [])},
385
+ }
386
+ if since:
387
+ meta["since"] = since
388
+ return meta
389
+
390
+
391
+ def changed_files(repo_dir: str, base: str) -> list:
392
+ """Paths that differ between the merge base with `base` and HEAD, sorted. ValueError when git refuses."""
393
+ proc = subprocess.run([*filetypes.GIT, "diff", "-z", "--name-only", f"{base}...HEAD"], cwd=repo_dir, capture_output=True)
394
+ if proc.returncode != 0:
395
+ raise ValueError((proc.stderr.decode("utf-8", "replace").strip() or f"git diff {base}...HEAD failed"))
396
+ return sorted(p.decode("utf-8", "surrogateescape") for p in proc.stdout.split(b"\0") if p)
397
+
398
+
399
+ def save_meta(meta: dict, out_dir: str) -> None:
400
+ with open(os.path.join(out_dir, "meta.json"), "w") as fh:
401
+ json.dump(meta, fh, indent=2)
402
+
403
+
404
+ def write_meta(repo_dir: str, out_dir: str) -> dict:
405
+ meta = collect_meta(repo_dir)
406
+ save_meta(meta, out_dir)
407
+ return meta
gitmole/textfmt.py ADDED
@@ -0,0 +1,91 @@
1
+ """Small text helpers that make the report easier to read: path elision, finding grouping, tallies."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ ELLIPSIS = "…"
7
+
8
+
9
+ def shorten_path(path: str, max_len: int) -> str:
10
+ """Elide middle directories so the path fits, keeping the file name whole:
11
+ a/b/c/d/e.py -> a/…/d/e.py -> …/d/e.py -> …/e.py."""
12
+ if len(path) <= max_len or "/" not in path:
13
+ return path
14
+ parts = path.split("/")
15
+ candidates = [f"{parts[0]}/{ELLIPSIS}/" + "/".join(parts[-2:])] if len(parts) > 3 else []
16
+ if len(parts) > 2:
17
+ candidates.append(f"{ELLIPSIS}/" + "/".join(parts[-2:]))
18
+ candidates.append(f"{ELLIPSIS}/{parts[-1]}")
19
+ for c in candidates:
20
+ if len(c) <= max_len:
21
+ return c
22
+ return candidates[-1]
23
+
24
+
25
+ def times(n: int) -> str:
26
+ """How often something happened, in words for the small numbers: once, twice, 3 times."""
27
+ return {1: "once", 2: "twice"}.get(n, f"{n} times")
28
+
29
+
30
+ _ADVICE_VERBS = ("Add ", "Set ", "Rotate ", "Pair ", "Rerun ", "Expect ", "Consider ", "Use ", "Review ", "Merge ", "Split ", "Move ", "Extract ")
31
+ _SENTENCE_END = re.compile(r"(?<=[.!?])\s+(?=[A-Z])")
32
+
33
+
34
+ def split_advice(detail: str):
35
+ """(statement, advice): the last sentence is advice when it is an instruction and there is
36
+ more than one sentence. The statement loses its trailing period only when advice was split off."""
37
+ sentences = _SENTENCE_END.split(detail.strip())
38
+ if len(sentences) < 2 or not sentences[-1].startswith(_ADVICE_VERBS):
39
+ return detail.strip(), None
40
+ statement = " ".join(sentences[:-1]).rstrip(".")
41
+ return statement, sentences[-1]
42
+
43
+
44
+ def _statement_and_advice(f: dict):
45
+ """A finding's facts and its next step. Rules say which part is the advice; for a finding
46
+ without that field (older JSON, a hand-made dict) the last sentence is taken when it is an
47
+ instruction."""
48
+ detail = f["detail"].strip()
49
+ advice = f.get("advice")
50
+ if advice:
51
+ statement = detail[:-len(advice)].rstrip() if detail.endswith(advice) else detail
52
+ return statement.rstrip("."), advice
53
+ return split_advice(detail)
54
+
55
+
56
+ def group_findings(findings: list) -> list:
57
+ """Merge findings that share a title into one entry with an item list and the distinct next
58
+ steps its items carry, in first-seen order. Order: by severity, then first appearance."""
59
+ order = {"critical": 0, "warning": 1, "info": 2}
60
+ groups, index = [], {}
61
+ for f in findings:
62
+ statement, advice = _statement_and_advice(f)
63
+ key = f["title"]
64
+ if key not in index:
65
+ index[key] = len(groups)
66
+ groups.append({"severity": f["severity"], "title": key, "items": [], "advice": []})
67
+ g = groups[index[key]]
68
+ g["items"].append(statement)
69
+ if advice and advice not in g["advice"]:
70
+ g["advice"].append(advice)
71
+ if order[f["severity"]] < order[g["severity"]]:
72
+ g["severity"] = f["severity"]
73
+ for g in groups:
74
+ if len(g["items"]) > 1:
75
+ g["title"] = f"{g['title']} ({len(g['items'])})"
76
+ groups.sort(key=lambda g: order[g["severity"]])
77
+ return groups
78
+
79
+
80
+ def tally(findings: list) -> str:
81
+ counts = {"critical": 0, "warning": 0, "info": 0}
82
+ for f in findings:
83
+ counts[f["severity"]] += 1
84
+ parts = []
85
+ if counts["critical"]:
86
+ parts.append(f"{counts['critical']} critical")
87
+ if counts["warning"]:
88
+ parts.append(f"{counts['warning']} warning" + ("s" if counts["warning"] != 1 else ""))
89
+ if counts["info"]:
90
+ parts.append(f"{counts['info']} note" + ("s" if counts["info"] != 1 else ""))
91
+ return ", ".join(parts) if parts else "nothing flagged"
gitmole/trend.py ADDED
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Complexity over time for the top hotspots: scc on each file's contents at sampled commits.
3
+
4
+ Runs as a pipeline step: `python -m gitmole.trend OUT_DIR [--repo DIR] [--samples N] [--top N]`,
5
+ from inside the repository (or with --repo). Reads size.json and maat-revisions.csv the earlier
6
+ steps wrote, picks the top hotspots still in the tree, and writes trend.json:
7
+ {"samples": [DATE, ...], "files": {PATH: [[DATE, complexity, code], ...]}}.
8
+
9
+ The pure helpers below are also what the renderer and the findings use."""
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import datetime as dt
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+
20
+ from . import filetypes, hotspots, load, maat
21
+
22
+ BLOCKS = "▁▂▃▄▅▆▇█"
23
+
24
+
25
+ def sample_dates(first: str, last: str, n: int) -> list:
26
+ a, b = dt.date.fromisoformat(first), dt.date.fromisoformat(last)
27
+ if b <= a:
28
+ return [last]
29
+ span = (b - a).days
30
+ n = max(2, min(n, span // 28 + 1))
31
+ out = [(a + dt.timedelta(days=round(span * i / (n - 1)))).isoformat() for i in range(n)]
32
+ out[-1] = last
33
+ return out
34
+
35
+
36
+ def change_over_year(series: list, last_date: str) -> str:
37
+ if len(series) < 2:
38
+ return "-"
39
+ year_ago = maat.months_before(last_date, 12)
40
+ before = [s for s in series if s[0] <= year_ago]
41
+ base = before[-1] if before else series[0]
42
+ then, now = base[1], series[-1][1]
43
+ if not then:
44
+ return "-"
45
+ pct = round(100 * (now - then) / then)
46
+ if abs(pct) < 10:
47
+ return "="
48
+ return f"{pct:+d}%"
49
+
50
+
51
+ def sparkline(series: list) -> str:
52
+ """One block per sample, scaled between the smallest and the largest. Fewer than two samples
53
+ draw nothing: a single block would read as a flat trend nobody measured."""
54
+ values = [s[1] for s in series]
55
+ if len(values) < 2:
56
+ return ""
57
+ lo, hi = min(values), max(values)
58
+ if hi == lo:
59
+ return BLOCKS[0] * len(values)
60
+ return "".join(BLOCKS[int((v - lo) / (hi - lo) * (len(BLOCKS) - 1))] for v in values)
61
+
62
+
63
+ def rev_before(repo: str, date: str, end_of_day: bool = True):
64
+ """The last commit at or before `date` by committer date, or None when there is none.
65
+
66
+ The two callers want different edges of the day. A trend sample is "the code as it stood on
67
+ that date", so it takes everything committed during the day (T23:59:59). The backtest asks
68
+ "what did we know before T", so it must stop as the day begins (T00:00:00) and leave the
69
+ commits made on T itself to the future it is being scored against.
70
+
71
+ Raises RuntimeError with git's own message when git fails: an unreadable repository is not
72
+ the same answer as a history that does not reach back that far."""
73
+ bound = f"{date}T23:59:59" if end_of_day else f"{date}T00:00:00"
74
+ proc = subprocess.run(["git", "rev-list", "-1", f"--before={bound}", "HEAD"], cwd=repo, capture_output=True, text=True)
75
+ if proc.returncode != 0:
76
+ raise RuntimeError((proc.stderr or "").strip().splitlines()[0] if (proc.stderr or "").strip()
77
+ else f"git rev-list --before={bound} exited {proc.returncode}")
78
+ return proc.stdout.strip() or None
79
+
80
+
81
+ def measure(repo: str, rev: str, files: list, out_dir: str) -> dict:
82
+ """{path: (complexity, code)} for the files that exist at rev, from one scc run over their contents.
83
+
84
+ The copies go under `out_dir`, not the system temp directory, so a SIGKILL leaves them where
85
+ the next run's clear_outputs finds them instead of filling /tmp."""
86
+ out = {}
87
+ with tempfile.TemporaryDirectory(dir=out_dir, prefix=".trend-") as tmp:
88
+ present = []
89
+ for path in files:
90
+ proc = subprocess.run([*filetypes.GIT, "show", f"{rev}:{path}"], cwd=repo, capture_output=True)
91
+ if proc.returncode != 0:
92
+ continue
93
+ target = os.path.join(tmp, path)
94
+ os.makedirs(os.path.dirname(target), exist_ok=True)
95
+ with open(target, "wb") as fh:
96
+ fh.write(proc.stdout)
97
+ present.append(path)
98
+ if not present:
99
+ return out
100
+ scc = subprocess.run(["scc", "--by-file", "--format", "json"], cwd=tmp, capture_output=True, text=True)
101
+ if scc.returncode != 0:
102
+ return out
103
+ for path, info in load.parse_scc(scc.stdout)["files"].items():
104
+ out[path] = (info["complexity"], info["code"])
105
+ return out
106
+
107
+
108
+ def top_files(out_dir: str, n: int) -> list:
109
+ meta = json.loads(load._read(out_dir, "meta.json") or "{}")
110
+ size = load.parse_scc(load._read(out_dir, "size.json"), filetypes.parse(meta["file_types"]) if "file_types" in meta else None)
111
+ revisions = load.parse_maat_csv(load._read(out_dir, "maat-revisions.csv"))
112
+ ranked = hotspots.ranked({"size": size, "revisions": revisions})
113
+ return [h["entity"] for h in ranked if h["code"] is not None][:n]
114
+
115
+
116
+ def main(argv=None) -> int:
117
+ p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
118
+ p.add_argument("out")
119
+ p.add_argument("--repo", default=".")
120
+ p.add_argument("--samples", type=int, default=12)
121
+ p.add_argument("--top", type=int, default=10)
122
+ args = p.parse_args(argv)
123
+ meta = json.loads(load._read(args.out, "meta.json") or "{}")
124
+ if not (meta.get("first_date") and meta.get("last_date") and os.path.exists(os.path.join(args.out, "size.json"))
125
+ and os.path.exists(os.path.join(args.out, "maat-revisions.csv"))):
126
+ print("trend: meta.json with dates, size.json and maat-revisions.csv are needed", file=sys.stderr)
127
+ return 2
128
+ files = top_files(args.out, args.top)
129
+ dates = sample_dates(meta["first_date"], meta["last_date"], args.samples)
130
+ series = {f: [] for f in files}
131
+ for date in dates:
132
+ try:
133
+ rev = rev_before(args.repo, date)
134
+ except RuntimeError as e:
135
+ print(f"trend: {e}", file=sys.stderr)
136
+ return 2
137
+ if not rev:
138
+ continue
139
+ for path, (cplx, code) in measure(args.repo, rev, files, args.out).items():
140
+ series[path].append([date, cplx, code])
141
+ data = {"samples": dates, "files": series}
142
+ fd, tmp = tempfile.mkstemp(dir=args.out, prefix=".trend-", suffix=".json")
143
+ try:
144
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
145
+ json.dump(data, fh)
146
+ os.replace(tmp, os.path.join(args.out, "trend.json"))
147
+ finally:
148
+ if os.path.exists(tmp):
149
+ os.remove(tmp)
150
+ return 0
151
+
152
+
153
+ if __name__ == "__main__":
154
+ sys.exit(main())