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/cli.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Command line entry point: gitmole <path | owner/repo | url> [--out DIR] [--no-run]."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from rich.console import Console, Group
|
|
13
|
+
from rich.live import Live
|
|
14
|
+
from rich.spinner import Spinner
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from . import __version__, banner, filetypes, findings, load, loss, run
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_args(argv):
|
|
21
|
+
p = argparse.ArgumentParser(prog="gitmole", description="Analyse a git repository offline and print a report.")
|
|
22
|
+
p.add_argument("target", help="local clone path, owner/repo, or git URL")
|
|
23
|
+
p.add_argument("--out", help="output directory (default: analysis-<repo> next to the clone, or in cwd for remote targets)")
|
|
24
|
+
p.add_argument("--no-run", action="store_true", help="skip the tools; re-render the report from an existing output directory")
|
|
25
|
+
p.add_argument("--workers", type=int, default=6, help="how many tools to run at once")
|
|
26
|
+
p.add_argument("--plots", action="store_true", help="also run git-of-theseus for the code-age and survival plots")
|
|
27
|
+
p.add_argument("--deep", action="store_true", help="run code age and plots even when the repo exceeds the blame budget")
|
|
28
|
+
p.add_argument("--time-budget", type=float, default=60, metavar="SECONDS", help="skip code age when its projected time exceeds this (default 60)")
|
|
29
|
+
p.add_argument("--budget", type=int, default=50000, help="max git blames before plots are skipped (default 50000)")
|
|
30
|
+
p.add_argument("--timeout", type=float, default=900, help="seconds any single tool may run before being killed (default 900)")
|
|
31
|
+
p.add_argument("--ignore-data", action="store_true", help="exclude data-like files (csv, json, lock, minified, vendored) from code age, function metrics and plots")
|
|
32
|
+
p.add_argument("--ignore", action="append", default=[], metavar="GLOB", help="extra ignore pattern for code age, function metrics and plots (repeatable)")
|
|
33
|
+
p.add_argument("--since", metavar="WHEN", help="only analyse history newer than this: 2y, 18m, 90d or YYYY-MM-DD (code age is always the whole tree)")
|
|
34
|
+
p.add_argument("--gone", type=int, default=loss.DEFAULT_MONTHS, metavar="MONTHS", help="a person with no commits this many months before the last commit counts as gone (default 12)")
|
|
35
|
+
p.add_argument("--file-types", metavar="LIST", help="comma-separated extensions to treat as code (default: a built-in source list), or 'all'")
|
|
36
|
+
p.add_argument("--list-file-types", action="store_true", help="list the file types in the repository, with counts and whether they count as code, then exit")
|
|
37
|
+
p.add_argument("--duplicates", action="store_true", help="also look for duplicated code blocks (minutes and gigabytes on a large repo; function metrics alone take seconds)")
|
|
38
|
+
p.add_argument("--full", action="store_true", help="every column and every row in the terminal report, and the test files the default tables hide (the default is the tighter, readable one)")
|
|
39
|
+
p.add_argument("--json", metavar="PATH", help="write the report and findings as JSON to PATH, or - for stdout")
|
|
40
|
+
p.add_argument("--markdown", metavar="PATH", help="write the report as Markdown to PATH, or - for stdout")
|
|
41
|
+
p.add_argument("--fail-on", choices=findings.SEVERITIES, help="exit 3 if any finding is at this severity or worse")
|
|
42
|
+
p.add_argument("--risk", metavar="BASE", help="score the files changed since BASE (merge base with HEAD) with the watch list's score; needs a local path")
|
|
43
|
+
p.add_argument("--risk-threshold", type=float, metavar="N", help="with --risk: exit 3 when the change-risk total exceeds N")
|
|
44
|
+
p.add_argument("--version", action="version", version=f"gitmole {__version__}")
|
|
45
|
+
return p.parse_args(argv)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_control = run.Control()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def interrupt(*_):
|
|
52
|
+
"""Ctrl-C: kill every running step's process group; the run loop then exits 130."""
|
|
53
|
+
_control.cancel()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv=None, console: Console = None, tool_check=run.missing_tools, planner=run.plan, estimator=run.estimate_blames,
|
|
57
|
+
lister=run.list_repos, cloner=run.clone, lizard_check=run.has_lizard) -> int:
|
|
58
|
+
global _control
|
|
59
|
+
_control = run.Control()
|
|
60
|
+
if threading.current_thread() is threading.main_thread():
|
|
61
|
+
signal.signal(signal.SIGINT, interrupt)
|
|
62
|
+
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
63
|
+
console = console or Console()
|
|
64
|
+
err = Console(stderr=True) if console.file is sys.stdout else console
|
|
65
|
+
rc = _check_args(args, err)
|
|
66
|
+
if rc is not None:
|
|
67
|
+
return rc
|
|
68
|
+
# When an export goes to stdout, everything else (banner, progress, report) moves to stderr.
|
|
69
|
+
quiet = "-" in (args.json, args.markdown)
|
|
70
|
+
ui = Console(stderr=True) if quiet else console
|
|
71
|
+
|
|
72
|
+
rc, now = _resolve_time(args, err, ui)
|
|
73
|
+
if rc is not None:
|
|
74
|
+
return rc
|
|
75
|
+
|
|
76
|
+
if args.no_run:
|
|
77
|
+
return _no_run(args, console, ui, err)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
kind, target = run.classify_target(args.target)
|
|
81
|
+
except ValueError as e:
|
|
82
|
+
err.print(f"[red]{e}[/red]")
|
|
83
|
+
return 2
|
|
84
|
+
rc = _check_args(args, err, kind)
|
|
85
|
+
if rc is not None:
|
|
86
|
+
return rc
|
|
87
|
+
|
|
88
|
+
args.now = now
|
|
89
|
+
if args.since_date:
|
|
90
|
+
ui.print(f"[dim]history bounded: since {args.since_date}[/dim]")
|
|
91
|
+
|
|
92
|
+
if args.list_file_types:
|
|
93
|
+
return _list_file_types(target, args, console)
|
|
94
|
+
|
|
95
|
+
missing = tool_check(plots=args.plots)
|
|
96
|
+
if missing:
|
|
97
|
+
err.print("[red]missing tools:[/red] " + ", ".join(missing))
|
|
98
|
+
err.print("brew install scc git-sizer gitleaks; see README.md for other ways")
|
|
99
|
+
return 2
|
|
100
|
+
args.lizard = lizard_check() # decided once, for every repository this run analyses
|
|
101
|
+
|
|
102
|
+
rc, repo_dir, out_dir = _resolve_target(kind, target, args, console, ui, err, planner, estimator, lister, cloner)
|
|
103
|
+
if rc is not None:
|
|
104
|
+
return rc
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
_analyse(repo_dir, out_dir, args, ui, planner, estimator)
|
|
108
|
+
except Interrupted:
|
|
109
|
+
return 130
|
|
110
|
+
except NoCommits as e:
|
|
111
|
+
err.print(f"[red]{e}[/red]")
|
|
112
|
+
return 2
|
|
113
|
+
return _render(out_dir, console, ui, args, err)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_args(args, err, kind=None) -> int | None:
|
|
117
|
+
"""The argument combinations that cannot work, in one place: 2 and a message, or None. Called
|
|
118
|
+
once on the arguments alone, then again with the target's `kind` for the checks that need it."""
|
|
119
|
+
if kind is None:
|
|
120
|
+
bad = "--risk-threshold needs --risk" if args.risk_threshold is not None and not args.risk else None
|
|
121
|
+
elif kind == "path":
|
|
122
|
+
bad = None
|
|
123
|
+
else:
|
|
124
|
+
bad = ("--risk needs a local path" if args.risk else
|
|
125
|
+
"--list-file-types needs a local path" if args.list_file_types else None)
|
|
126
|
+
if bad:
|
|
127
|
+
err.print(f"[red]{bad}[/red]")
|
|
128
|
+
return 2
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _resolve_time(args, err, ui):
|
|
133
|
+
"""Resolve GITMOLE_NOW and --since into (None, now) and args.since_date, or (rc, None) on bad input."""
|
|
134
|
+
now = os.environ.get("GITMOLE_NOW") or None
|
|
135
|
+
if now:
|
|
136
|
+
from . import maat
|
|
137
|
+
try:
|
|
138
|
+
maat.validate_now(now)
|
|
139
|
+
except ValueError as e:
|
|
140
|
+
err.print(f"[red]GITMOLE_NOW:[/red] {e}")
|
|
141
|
+
return 2, None
|
|
142
|
+
ui.print(f"[yellow]reference date fixed by GITMOLE_NOW:[/yellow] {now}")
|
|
143
|
+
args.since_date = None
|
|
144
|
+
if args.since:
|
|
145
|
+
import datetime as _dt
|
|
146
|
+
try:
|
|
147
|
+
args.since_date = run.parse_since(args.since, now or _dt.date.today().isoformat())
|
|
148
|
+
except ValueError as e:
|
|
149
|
+
err.print(f"[red]{e}[/red]")
|
|
150
|
+
return 2, None
|
|
151
|
+
return None, now
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _no_run(args, console, ui, err) -> int:
|
|
155
|
+
"""Handle --no-run: re-render an existing output directory instead of running the pipeline."""
|
|
156
|
+
if args.since:
|
|
157
|
+
err.print("[red]--since needs a run:[/red] a re-render cannot narrow an earlier analysis")
|
|
158
|
+
return 2
|
|
159
|
+
out_dir = os.path.abspath(args.target)
|
|
160
|
+
if not os.path.isfile(os.path.join(out_dir, "meta.json")):
|
|
161
|
+
err.print(f"[red]no gitmole output found in {out_dir}[/red] (expected meta.json)")
|
|
162
|
+
return 2
|
|
163
|
+
if ui.is_terminal:
|
|
164
|
+
ui.print(banner.neon())
|
|
165
|
+
return _render(out_dir, console, ui, args, err)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resolve_target(kind, target, args, console, ui, err, planner, estimator, lister, cloner):
|
|
169
|
+
"""Resolve the classified target to (repo_dir, out_dir) for _analyse, or a final return code for the
|
|
170
|
+
org and clone-failure paths. Returns (rc, repo_dir, out_dir); rc is None unless main should return early."""
|
|
171
|
+
if kind == "org":
|
|
172
|
+
return _portfolio(target, args, console, ui, planner, estimator, lister, cloner), None, None
|
|
173
|
+
|
|
174
|
+
if kind == "remote":
|
|
175
|
+
parent = tempfile.mkdtemp(prefix="gitmole-", dir=os.environ.get("TMPDIR"))
|
|
176
|
+
ui.print(f"[dim]cloning {target} into {parent}[/dim]")
|
|
177
|
+
try:
|
|
178
|
+
repo_dir = cloner(target, parent)
|
|
179
|
+
except run.GhError as e:
|
|
180
|
+
err.print(f"[red]could not clone {target}:[/red] {e}", soft_wrap=True)
|
|
181
|
+
return 2, None, None
|
|
182
|
+
else:
|
|
183
|
+
repo_dir = target
|
|
184
|
+
|
|
185
|
+
out_dir = run.output_dir(kind, repo_dir, args.out)
|
|
186
|
+
return None, repo_dir, out_dir
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Interrupted(Exception):
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class NoCommits(Exception):
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _types_spec(spec):
|
|
198
|
+
"""Normalise --file-types for the planner: None for the default, 'all', or a sorted comma list."""
|
|
199
|
+
if spec is None:
|
|
200
|
+
return None
|
|
201
|
+
parsed = filetypes.parse(spec)
|
|
202
|
+
return "all" if parsed is None else ",".join(sorted(parsed))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _list_file_types(repo_dir: str, args, console: Console) -> int:
|
|
206
|
+
from . import render
|
|
207
|
+
|
|
208
|
+
rows = [(k, n, "yes" if inc else "no") for k, n, inc in filetypes.discover(repo_dir, filetypes.parse(args.file_types))]
|
|
209
|
+
sec = render._section("File types", [("type", {}), ("files", render.RIGHT), ("code", {})], rows, note="no tracked files",
|
|
210
|
+
caption="code = analysed for hotspots, coupling and code age")
|
|
211
|
+
render.print_section(console, sec)
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _budgets(args, estimate, ui) -> tuple[bool, bool, float]:
|
|
216
|
+
"""Decide whether code age and plots fit their time/size budgets, printing a skip notice for each
|
|
217
|
+
one cut. The projected blame time comes back with them: meta.json records it."""
|
|
218
|
+
projected = float(estimate.get("seconds", 0.0))
|
|
219
|
+
age_ok = args.deep or projected <= args.time_budget
|
|
220
|
+
plots_ok = args.plots and (args.deep or estimate["blames"] <= args.budget)
|
|
221
|
+
if not age_ok:
|
|
222
|
+
ui.print(f"[yellow]code age skipped:[/yellow] a blame pass over {estimate.get('code_files', estimate['files']):,} files is projected "
|
|
223
|
+
f"to take about {projected:,.0f}s, over the {args.time_budget:,.0f}s time budget. "
|
|
224
|
+
f"Rerun with --deep to force it, raise --time-budget, or --ignore-data to shrink it.")
|
|
225
|
+
if args.plots and not plots_ok:
|
|
226
|
+
ui.print(f"[yellow]plots skipped:[/yellow] about {estimate['blames']:,} git blames "
|
|
227
|
+
f"({estimate['files']:,} files × {estimate['samples']} samples) exceeds the budget of {args.budget:,}. "
|
|
228
|
+
f"Rerun with --deep to force them, or --ignore-data to shrink them.")
|
|
229
|
+
return age_ok, plots_ok, projected
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _meta_for_run(repo_dir: str, args, estimate, age_ok: bool, plots_ok: bool, projected: float) -> tuple[dict, str | None]:
|
|
233
|
+
"""Collect this run's meta.json: repo facts plus a planned status record for every optional step.
|
|
234
|
+
Raises NoCommits when --since leaves no commits to analyse."""
|
|
235
|
+
types_spec = _types_spec(args.file_types)
|
|
236
|
+
|
|
237
|
+
meta = run.collect_meta(repo_dir, since=args.since_date)
|
|
238
|
+
meta["file_types"] = types_spec # the loader filters scc's size data the way every other step was filtered
|
|
239
|
+
meta["gone_months"] = args.gone
|
|
240
|
+
if args.since_date and meta["commits"] == 0:
|
|
241
|
+
raise NoCommits(f"no commits since {args.since_date}; widen --since")
|
|
242
|
+
if args.now:
|
|
243
|
+
meta["now"] = args.now
|
|
244
|
+
meta["age"] = {"status": "run" if age_ok else "skipped", "method": "blame", "files": estimate.get("code_files", estimate["files"]),
|
|
245
|
+
"projected_seconds": projected, "time_budget": args.time_budget}
|
|
246
|
+
if args.plots:
|
|
247
|
+
meta["plots"] = {"status": "run" if plots_ok else "skipped", "blames": estimate["blames"], "samples": estimate["samples"], "budget": args.budget}
|
|
248
|
+
lizard_ok = args.lizard
|
|
249
|
+
meta["functions"] = {"status": "planned" if lizard_ok else "skipped"} # "run" only once the step has finished
|
|
250
|
+
meta["trend"] = {"status": "planned"}
|
|
251
|
+
from . import maat as _maat
|
|
252
|
+
cut = _maat.months_before(meta["last_date"], 6) if meta["last_date"] else None
|
|
253
|
+
first = meta.get("first_date_all") or meta["first_date"] # the backtest reads the whole history, window or not
|
|
254
|
+
if cut and first and first <= _maat.months_before(cut, 6):
|
|
255
|
+
meta["backtest"] = {"status": "planned", "until": cut}
|
|
256
|
+
else:
|
|
257
|
+
cut = None
|
|
258
|
+
meta["backtest"] = {"status": "skipped", "reason": "too little history to backtest"}
|
|
259
|
+
return meta, cut
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _record_statuses(meta, results, age_ok: bool, plots_ok: bool, lizard_ok: bool, cut) -> None:
|
|
263
|
+
"""Turn each planned step's exit code into its final status: run, timeout or failed."""
|
|
264
|
+
def status(step, default="run"):
|
|
265
|
+
rc = results.get(step, 0)
|
|
266
|
+
return default if rc == 0 else ("timeout" if rc == "timeout" else "failed")
|
|
267
|
+
if age_ok:
|
|
268
|
+
meta["age"]["status"] = status("code age")
|
|
269
|
+
if plots_ok:
|
|
270
|
+
meta["plots"]["status"] = status("git-of-theseus")
|
|
271
|
+
if lizard_ok:
|
|
272
|
+
meta["functions"]["status"] = status("functions")
|
|
273
|
+
if "trend" in results:
|
|
274
|
+
meta["trend"]["status"] = status("trend")
|
|
275
|
+
if cut and "backtest" in results:
|
|
276
|
+
meta["backtest"]["status"] = status("backtest")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _analyse(repo_dir: str, out_dir: str, args, ui: Console, planner, estimator) -> None:
|
|
280
|
+
"""Run the whole pipeline for one repository into out_dir."""
|
|
281
|
+
os.makedirs(os.path.join(out_dir, "theseus"), exist_ok=True)
|
|
282
|
+
log_path = os.path.join(out_dir, "run.log")
|
|
283
|
+
open(log_path, "w").close()
|
|
284
|
+
|
|
285
|
+
ignore = list(run.DATA_IGNORES if args.ignore_data else []) + list(args.ignore)
|
|
286
|
+
estimate = estimator(repo_dir, run.MONTH, ignore=ignore, types=filetypes.parse(args.file_types))
|
|
287
|
+
age_ok, plots_ok, projected = _budgets(args, estimate, ui)
|
|
288
|
+
|
|
289
|
+
meta, cut = _meta_for_run(repo_dir, args, estimate, age_ok, plots_ok, projected)
|
|
290
|
+
types_spec = meta["file_types"]
|
|
291
|
+
lizard_ok = args.lizard
|
|
292
|
+
run.clear_outputs(out_dir)
|
|
293
|
+
steps = planner(repo_dir, out_dir, branch=meta["branch"], age=age_ok, plots=plots_ok, ignore=ignore, types=types_spec, now=args.now,
|
|
294
|
+
since=args.since_date, lizard=lizard_ok, duplicates=args.duplicates, backtest=cut)
|
|
295
|
+
run.save_meta(meta, out_dir)
|
|
296
|
+
results = _execute(steps, log_path, repo_dir, args.workers, ui, timeout=args.timeout)
|
|
297
|
+
if _control.cancelled.is_set():
|
|
298
|
+
killed = [n for n, rc in results.items() if rc == "cancelled"]
|
|
299
|
+
ui.print(f"[red]interrupted:[/red] killed {len(killed)} step(s)")
|
|
300
|
+
raise Interrupted()
|
|
301
|
+
|
|
302
|
+
_record_statuses(meta, results, age_ok, plots_ok, lizard_ok, cut)
|
|
303
|
+
run.save_meta(meta, out_dir)
|
|
304
|
+
|
|
305
|
+
failed = [n for n, rc in results.items() if rc != 0]
|
|
306
|
+
if failed:
|
|
307
|
+
ui.print(f"[yellow]{len(failed)} step(s) did not complete:[/yellow] " + ", ".join(f"{n} ({results[n]})" for n in failed))
|
|
308
|
+
ui.print(f"[dim]details in {log_path}[/dim]\n")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _portfolio(owner: str, args, console: Console, ui: Console, planner, estimator, lister, cloner) -> int:
|
|
312
|
+
"""Analyse every non-archived repository of an owner and summarise them in one table."""
|
|
313
|
+
import json
|
|
314
|
+
|
|
315
|
+
from . import render
|
|
316
|
+
|
|
317
|
+
base = os.path.abspath(args.out) if args.out else os.path.join(os.getcwd(), f"analysis-{owner}")
|
|
318
|
+
try:
|
|
319
|
+
repos = lister(owner)
|
|
320
|
+
except run.GhError as e:
|
|
321
|
+
ui.print(f"[red]could not list repositories for {owner}:[/red] {e}", soft_wrap=True)
|
|
322
|
+
return 2
|
|
323
|
+
if not repos:
|
|
324
|
+
ui.print(f"[red]no repositories found for {owner}[/red]")
|
|
325
|
+
return 2
|
|
326
|
+
parent = tempfile.mkdtemp(prefix="gitmole-", dir=os.environ.get("TMPDIR"))
|
|
327
|
+
reports = []
|
|
328
|
+
for i, name in enumerate(repos, 1):
|
|
329
|
+
ui.print(f"[bold]{name}[/bold] [dim]({i}/{len(repos)})[/dim]")
|
|
330
|
+
try:
|
|
331
|
+
repo_dir = cloner(f"{owner}/{name}", parent)
|
|
332
|
+
except run.GhError as e:
|
|
333
|
+
ui.print(f"[red]could not clone {owner}/{name}:[/red] {e}", soft_wrap=True)
|
|
334
|
+
continue
|
|
335
|
+
out_dir = os.path.join(base, name)
|
|
336
|
+
try:
|
|
337
|
+
_analyse(repo_dir, out_dir, args, ui, planner, estimator)
|
|
338
|
+
except Interrupted:
|
|
339
|
+
return 130
|
|
340
|
+
except NoCommits as e:
|
|
341
|
+
ui.print(f"[yellow]{name}:[/yellow] {e}; skipped")
|
|
342
|
+
continue
|
|
343
|
+
report = load.load_report(out_dir)
|
|
344
|
+
reports.append((name, report, findings.evaluate(report)))
|
|
345
|
+
|
|
346
|
+
def export_path(p):
|
|
347
|
+
return p if p == "-" or os.path.isabs(p) else os.path.join(base, p)
|
|
348
|
+
|
|
349
|
+
if args.json:
|
|
350
|
+
_write(json.dumps(render.portfolio_json(owner, reports), indent=2) + "\n", export_path(args.json), console)
|
|
351
|
+
if args.markdown:
|
|
352
|
+
_write(render.portfolio_markdown(owner, reports), export_path(args.markdown), console)
|
|
353
|
+
if "-" not in (args.json, args.markdown):
|
|
354
|
+
render.print_section(console, render.portfolio_section(reports))
|
|
355
|
+
console.print(Text(f"\nPer-repository results in {base}", style="dim"), soft_wrap=True)
|
|
356
|
+
all_found = [f for _, _, found in reports for f in found]
|
|
357
|
+
if args.fail_on and any(findings.SEVERITIES.index(f["severity"]) <= findings.SEVERITIES.index(args.fail_on) for f in all_found):
|
|
358
|
+
return 3
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _execute(steps, log_path, repo_dir, workers, console, timeout=None) -> dict:
|
|
363
|
+
"""Run the steps under a Live display: the banner pulsing above a status line."""
|
|
364
|
+
active, lock = set(), threading.Lock()
|
|
365
|
+
started = time.monotonic()
|
|
366
|
+
spinner = Spinner("dots", style="cyan")
|
|
367
|
+
frame = banner.frames()
|
|
368
|
+
|
|
369
|
+
def label() -> str:
|
|
370
|
+
with lock:
|
|
371
|
+
names = ", ".join(sorted(active))
|
|
372
|
+
return f"running {names}" if names else "finishing"
|
|
373
|
+
|
|
374
|
+
def view():
|
|
375
|
+
if not console.is_terminal:
|
|
376
|
+
return Text(label())
|
|
377
|
+
status = Group(spinner, Text(" " + label(), style="dim"))
|
|
378
|
+
return Group(next(frame), status)
|
|
379
|
+
|
|
380
|
+
def on_start(name):
|
|
381
|
+
with lock:
|
|
382
|
+
active.add(name)
|
|
383
|
+
|
|
384
|
+
def on_done(name, rc):
|
|
385
|
+
with lock:
|
|
386
|
+
active.discard(name)
|
|
387
|
+
|
|
388
|
+
results = {}
|
|
389
|
+
with Live(view(), console=console, refresh_per_second=10, transient=False) as live:
|
|
390
|
+
worker = threading.Thread(
|
|
391
|
+
target=lambda: results.update(run.execute(steps, log_path=log_path, cwd=repo_dir, workers=workers, on_start=on_start, on_done=on_done, timeout=timeout, control=_control)))
|
|
392
|
+
worker.start()
|
|
393
|
+
while worker.is_alive():
|
|
394
|
+
live.update(view())
|
|
395
|
+
worker.join(0.1)
|
|
396
|
+
live.update(Group(banner.neon(), Text("")) if console.is_terminal else Text(""))
|
|
397
|
+
console.print(f"[dim]{len(steps)} steps in {time.monotonic() - started:.1f}s[/dim]\n")
|
|
398
|
+
return results
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _write(text: str, target: str, console: Console) -> None:
|
|
402
|
+
if target == "-":
|
|
403
|
+
console.print(Text(text), soft_wrap=True, end="")
|
|
404
|
+
else:
|
|
405
|
+
with open(target, "w") as fh:
|
|
406
|
+
fh.write(text)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _render(out_dir: str, console: Console, ui: Console, args, err: Console) -> int:
|
|
410
|
+
import json
|
|
411
|
+
|
|
412
|
+
from . import render
|
|
413
|
+
|
|
414
|
+
report = load.load_report(out_dir)
|
|
415
|
+
found = findings.evaluate(report)
|
|
416
|
+
risk = None
|
|
417
|
+
if args.risk:
|
|
418
|
+
try:
|
|
419
|
+
files = run.changed_files(report["meta"].get("path") or os.getcwd(), args.risk)
|
|
420
|
+
except ValueError as e:
|
|
421
|
+
err.print(f"[red]--risk {args.risk}:[/red] {e}", soft_wrap=True)
|
|
422
|
+
return 2
|
|
423
|
+
from . import watch
|
|
424
|
+
risk = {"base": args.risk, **watch.change_risk(report, files)}
|
|
425
|
+
if args.json:
|
|
426
|
+
_write(json.dumps(render.to_json(report, found, risk=risk), indent=2) + "\n", args.json, console)
|
|
427
|
+
if args.markdown:
|
|
428
|
+
_write(render.markdown(report, found, full=args.full, risk=risk, base=args.risk), args.markdown, console)
|
|
429
|
+
if "-" not in (args.json, args.markdown):
|
|
430
|
+
render.report(report, found, console, full=args.full, risk=risk, base=args.risk)
|
|
431
|
+
if args.fail_on and any(findings.SEVERITIES.index(f["severity"]) <= findings.SEVERITIES.index(args.fail_on) for f in found):
|
|
432
|
+
return 3
|
|
433
|
+
if risk is not None and args.risk_threshold is not None and risk["total"] > args.risk_threshold:
|
|
434
|
+
return 3
|
|
435
|
+
return 0
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
if __name__ == "__main__":
|
|
439
|
+
sys.exit(main())
|
gitmole/filetypes.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Which files count as source code, and how to get paths out of git safely.
|
|
2
|
+
Standalone so blame.py and maat.py can import it as scripts."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections import Counter
|
|
8
|
+
|
|
9
|
+
# git quotes paths with non-ASCII, quote, backslash or control characters unless told not to;
|
|
10
|
+
# every git call that prints paths goes through this prefix.
|
|
11
|
+
GIT = ["git", "-c", "core.quotePath=false"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def git_paths(repo: str, subcommand: str, *args) -> list:
|
|
15
|
+
"""Paths printed by a git subcommand, read NUL-separated as bytes so nothing is ever quoted
|
|
16
|
+
and a name that is not valid UTF-8 survives (as surrogate escapes that round-trip into argv)."""
|
|
17
|
+
out = subprocess.run([*GIT, subcommand, "-z", *args], cwd=repo, capture_output=True).stdout
|
|
18
|
+
return sorted(p.decode("utf-8", "surrogateescape") for p in out.split(b"\0") if p)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def unquote(path: str) -> str:
|
|
22
|
+
"""Undo git's C-style quoting ("src/\\303\\244.py", "say \\"hi\\".md") when it still appears,
|
|
23
|
+
e.g. in an older log export. Bytes that are not UTF-8 become U+FFFD."""
|
|
24
|
+
if len(path) < 2 or path[0] != '"' or path[-1] != '"':
|
|
25
|
+
return path
|
|
26
|
+
inner = path[1:-1]
|
|
27
|
+
return inner.encode("utf-8").decode("unicode_escape").encode("latin-1").decode("utf-8", "replace")
|
|
28
|
+
|
|
29
|
+
# Source extensions analysed by default. Docs, data and config are deliberately absent.
|
|
30
|
+
DEFAULT = frozenset("""
|
|
31
|
+
py pyi pyx js jsx mjs cjs ts tsx vue svelte java kt kts scala groovy clj cljs cljc edn
|
|
32
|
+
c cc cpp cxx h hh hpp hxx m mm cs fs fsx vb go rs swift rb erb rake php pl pm t lua r rmd
|
|
33
|
+
dart ex exs erl hrl hs lhs ml mli elm nim zig cr jl sql psql plsql sh bash zsh fish ps1 psm1 bat cmd
|
|
34
|
+
html htm css scss sass less styl tf tfvars hcl nix cmake gradle sbt proto thrift graphql gql
|
|
35
|
+
asm s v sv vhd vhdl cu cl glsl hlsl wgsl
|
|
36
|
+
""".split())
|
|
37
|
+
|
|
38
|
+
# Extensionless files that are code, by lowercased name.
|
|
39
|
+
NAMES = frozenset({"makefile", "dockerfile", "rakefile", "gemfile", "justfile", "vagrantfile", "cmakelists.txt", "build.gradle"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse(spec):
|
|
43
|
+
"""None -> DEFAULT; 'all' -> None (no filter); 'py, .SQL' -> {'py', 'sql'}."""
|
|
44
|
+
if spec is None:
|
|
45
|
+
return DEFAULT
|
|
46
|
+
if spec.strip().lower() == "all":
|
|
47
|
+
return None
|
|
48
|
+
return {t.strip().lstrip(".").lower() for t in spec.split(",") if t.strip()}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_TEST_PATH = re.compile(r"(^|/)(tests?|spec|specs|__tests__|testing)(/|$)|(^|/)(test_[^/]*|[^/]*_test\.[^/]+|[^/]*\.spec\.[^/]+|[^/]*\.test\.[^/]+)$", re.I)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_test_path(path: str) -> bool:
|
|
55
|
+
"""A test file or anything under a tests directory: changes with every fix, so not a signal on its own."""
|
|
56
|
+
return bool(_TEST_PATH.search(path))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def key(path: str) -> str:
|
|
60
|
+
"""The lowercased extension, or the whole lowercased name when there is none."""
|
|
61
|
+
name = path.rsplit("/", 1)[-1].lower()
|
|
62
|
+
if name in NAMES:
|
|
63
|
+
return name
|
|
64
|
+
return name.rsplit(".", 1)[-1] if "." in name.strip(".") else name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def matches(path: str, types) -> bool:
|
|
68
|
+
if types is None:
|
|
69
|
+
return True
|
|
70
|
+
k = key(path)
|
|
71
|
+
return k in types or k in NAMES and types is DEFAULT
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def discover(repo: str, types=DEFAULT) -> list:
|
|
75
|
+
"""[(key, file count, included)] over the index, most common first."""
|
|
76
|
+
counts = Counter(key(p) for p in git_paths(repo, "ls-files"))
|
|
77
|
+
rows = [(k, n, matches(f"x.{k}" if k not in NAMES else k, types)) for k, n in counts.items()]
|
|
78
|
+
return sorted(rows, key=lambda r: (-r[1], r[0]))
|