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