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