repoglance 0.4.0__tar.gz → 0.4.2__tar.gz
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.
- {repoglance-0.4.0 → repoglance-0.4.2}/.github/workflows/ci.yml +3 -1
- {repoglance-0.4.0 → repoglance-0.4.2}/CHANGELOG.md +17 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/CONTRIBUTING.md +2 -1
- {repoglance-0.4.0 → repoglance-0.4.2}/PKG-INFO +39 -4
- {repoglance-0.4.0 → repoglance-0.4.2}/README.md +37 -3
- repoglance-0.4.2/benchmarks/bench.py +45 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/pyproject.toml +10 -2
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/__init__.py +1 -1
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/cache.py +1 -1
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/cli.py +18 -15
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/gitinfo.py +12 -14
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/report.py +2 -3
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/scanner.py +38 -10
- {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_complexity.py +1 -1
- repoglance-0.4.2/tests/test_v041.py +78 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/.github/workflows/release.yml +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/.github/workflows/repoglance.yml +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/.gitignore +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/.pre-commit-hooks.yaml +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/.repoglance-badge.json +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/CODE_OF_CONDUCT.md +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/Dockerfile +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/LICENSE +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/RELEASING.md +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/SECURITY.md +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/action.yml +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/assets/badge.svg +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/assets/demo.svg +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/assets/demo.tape +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/assets/showcase/flask.svg +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/assets/showcase/httpie.svg +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/assets/showcase/requests.svg +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/docs/index.md +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/editors/vscode/README.md +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/editors/vscode/extension.js +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/editors/vscode/package.json +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/integrations/gitlab-ci.yml +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/mkdocs.yml +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/packaging/homebrew/repoglance.rb +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/__main__.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/badge.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/complexity.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/config.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/dedup.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/languages.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/metrics.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/plugins.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/vendored.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_cli.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_features.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_scanner.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_scanner_extra.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_v030.py +0 -0
- {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_v040.py +0 -0
|
@@ -21,7 +21,9 @@ jobs:
|
|
|
21
21
|
python-version: ${{ matrix.python-version }}
|
|
22
22
|
- name: Install
|
|
23
23
|
run: python -m pip install -e ".[dev]"
|
|
24
|
+
- name: Lint
|
|
25
|
+
run: python -m ruff check src/ tests/
|
|
24
26
|
- name: Test
|
|
25
|
-
run: python -m pytest -q
|
|
27
|
+
run: python -m pytest -q --cov=repoglance --cov-report=term-missing
|
|
26
28
|
- name: Smoke test the CLI
|
|
27
29
|
run: repoglance --json .
|
|
@@ -4,6 +4,23 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.4.2] - 2026-08-01
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Multicore scanning for large repositories**: complexity parsing now runs in
|
|
11
|
+
a process pool when a scan is big enough (CPU-bound work the GIL kept threads
|
|
12
|
+
from parallelizing), roughly halving cold-scan time on large monorepos. Force
|
|
13
|
+
the mode with `--processes` / `--threads`. Django (415k LOC): ~20.5s → ~10.1s.
|
|
14
|
+
|
|
15
|
+
## [0.4.1] - 2026-08-01
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `--sarif-threshold` to control the minimum complexity reported in SARIF.
|
|
19
|
+
- Ruff linting and coverage reporting in CI; a reproducible `benchmarks/bench.py`.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Internal refactors (`gitinfo.collect`) and lint cleanups; no behavior change.
|
|
23
|
+
|
|
7
24
|
## [0.4.0] - 2026-08-01
|
|
8
25
|
|
|
9
26
|
### Added
|
|
@@ -23,7 +23,8 @@ Optionally give it a color in `LANG_COLOR` and a comment prefix in
|
|
|
23
23
|
|
|
24
24
|
## Guidelines
|
|
25
25
|
|
|
26
|
-
- Keep the runtime dependencies
|
|
26
|
+
- Keep the runtime dependencies lean (`rich`, `click`, `lizard`, and `tomli` on
|
|
27
|
+
Python < 3.11). Anything heavier needs a
|
|
27
28
|
strong justification.
|
|
28
29
|
- Every behavior the report shows must also be reachable via `--json`.
|
|
29
30
|
- Add or update a test in `tests/` for any logic change.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: repoglance
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Instant, gorgeous insight into any code repository — languages, complexity hotspots, TODOs and git activity in one terminal command.
|
|
5
5
|
Project-URL: Homepage, https://github.com/SRJ-ai/repoglance
|
|
6
6
|
Project-URL: Repository, https://github.com/SRJ-ai/repoglance
|
|
@@ -27,6 +27,7 @@ Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
|
27
27
|
Provides-Extra: dev
|
|
28
28
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
29
29
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
30
31
|
Description-Content-Type: text/markdown
|
|
31
32
|
|
|
32
33
|
<div align="center">
|
|
@@ -40,7 +41,6 @@ language breakdown, complexity hotspots, TODO tracker, biggest files, and git
|
|
|
40
41
|
activity. **Zero config. Zero API keys. Zero telemetry.**
|
|
41
42
|
|
|
42
43
|
[](https://pypi.org/project/repoglance/)
|
|
43
|
-
[](https://pypi.org/project/repoglance/)
|
|
44
44
|
[](https://github.com/SRJ-ai/repoglance/actions/workflows/ci.yml)
|
|
45
45
|
[](https://pypi.org/project/repoglance/)
|
|
46
46
|
[](LICENSE)
|
|
@@ -218,7 +218,7 @@ jobs:
|
|
|
218
218
|
runs-on: ubuntu-latest
|
|
219
219
|
steps:
|
|
220
220
|
- uses: actions/checkout@v4
|
|
221
|
-
- uses: SRJ-ai/repoglance@
|
|
221
|
+
- uses: SRJ-ai/repoglance@v0.4.0
|
|
222
222
|
with:
|
|
223
223
|
fail-under: "70" # optional health gate
|
|
224
224
|
max-complexity: "25" # optional complexity gate
|
|
@@ -232,7 +232,7 @@ The report also lands in the workflow's **job summary** every run.
|
|
|
232
232
|
# .pre-commit-config.yaml
|
|
233
233
|
repos:
|
|
234
234
|
- repo: https://github.com/SRJ-ai/repoglance
|
|
235
|
-
rev: v0.
|
|
235
|
+
rev: v0.4.0
|
|
236
236
|
hooks:
|
|
237
237
|
- id: repoglance
|
|
238
238
|
args: ["--ci", "--fail-under", "70"]
|
|
@@ -278,6 +278,41 @@ docker run --rm -v "$PWD:/repo" repoglance /repo
|
|
|
278
278
|
A ready-to-copy GitLab CI job lives in
|
|
279
279
|
[`integrations/gitlab-ci.yml`](integrations/gitlab-ci.yml).
|
|
280
280
|
|
|
281
|
+
## Built for large monorepos
|
|
282
|
+
|
|
283
|
+
repoglance is designed to stay useful at the scale of a big-tech monorepo —
|
|
284
|
+
millions of lines, thousands of files — not just small projects:
|
|
285
|
+
|
|
286
|
+
- **Diff mode** (`--since <rev>`) analyzes only the files a change touches, so a
|
|
287
|
+
PR check on a giant repo stays fast regardless of total size.
|
|
288
|
+
- **Incremental cache** (`--cache`) reuses unchanged files by mtime + size —
|
|
289
|
+
repeat runs are near-instant (see below).
|
|
290
|
+
- **True multicore scanning**: for large repos it automatically uses a process
|
|
291
|
+
pool (complexity parsing is CPU-bound, so threads alone can't use every core),
|
|
292
|
+
roughly halving cold-scan time. Force it with `--processes` / `--threads`.
|
|
293
|
+
- **`.gitignore`-aware** and **vendored/generated exclusion**, so third-party
|
|
294
|
+
and generated code doesn't drown the signal.
|
|
295
|
+
- **Path scoping** with `--include` / `--exclude` globs for per-team slices of a
|
|
296
|
+
shared repo.
|
|
297
|
+
|
|
298
|
+
> Note: repoglance is an independent open-source project. It is not affiliated
|
|
299
|
+
> with, endorsed by, or used by any company named for scale comparison.
|
|
300
|
+
|
|
301
|
+
## Performance
|
|
302
|
+
|
|
303
|
+
Measured on Django (3,180 files, ~415k lines of code), single machine:
|
|
304
|
+
|
|
305
|
+
| Run | Time |
|
|
306
|
+
|---|--:|
|
|
307
|
+
| Cold scan, threads | ~20.5 s |
|
|
308
|
+
| Cold scan, process pool (auto on large repos) | ~10.1 s |
|
|
309
|
+
| Re-run with `--cache` | ~1.1 s |
|
|
310
|
+
|
|
311
|
+
The cold scan is dominated by real per-function complexity parsing. The process
|
|
312
|
+
pool spreads that across cores (~2× here); the incremental cache (`--cache
|
|
313
|
+
<file>`) reuses unchanged files by mtime + size, so repeat runs — the common
|
|
314
|
+
case in editors and CI — are roughly **20× faster** than a cold thread scan.
|
|
315
|
+
|
|
281
316
|
## Design goals
|
|
282
317
|
|
|
283
318
|
- **Fast** — a single pass, no external services.
|
|
@@ -9,7 +9,6 @@ language breakdown, complexity hotspots, TODO tracker, biggest files, and git
|
|
|
9
9
|
activity. **Zero config. Zero API keys. Zero telemetry.**
|
|
10
10
|
|
|
11
11
|
[](https://pypi.org/project/repoglance/)
|
|
12
|
-
[](https://pypi.org/project/repoglance/)
|
|
13
12
|
[](https://github.com/SRJ-ai/repoglance/actions/workflows/ci.yml)
|
|
14
13
|
[](https://pypi.org/project/repoglance/)
|
|
15
14
|
[](LICENSE)
|
|
@@ -187,7 +186,7 @@ jobs:
|
|
|
187
186
|
runs-on: ubuntu-latest
|
|
188
187
|
steps:
|
|
189
188
|
- uses: actions/checkout@v4
|
|
190
|
-
- uses: SRJ-ai/repoglance@
|
|
189
|
+
- uses: SRJ-ai/repoglance@v0.4.0
|
|
191
190
|
with:
|
|
192
191
|
fail-under: "70" # optional health gate
|
|
193
192
|
max-complexity: "25" # optional complexity gate
|
|
@@ -201,7 +200,7 @@ The report also lands in the workflow's **job summary** every run.
|
|
|
201
200
|
# .pre-commit-config.yaml
|
|
202
201
|
repos:
|
|
203
202
|
- repo: https://github.com/SRJ-ai/repoglance
|
|
204
|
-
rev: v0.
|
|
203
|
+
rev: v0.4.0
|
|
205
204
|
hooks:
|
|
206
205
|
- id: repoglance
|
|
207
206
|
args: ["--ci", "--fail-under", "70"]
|
|
@@ -247,6 +246,41 @@ docker run --rm -v "$PWD:/repo" repoglance /repo
|
|
|
247
246
|
A ready-to-copy GitLab CI job lives in
|
|
248
247
|
[`integrations/gitlab-ci.yml`](integrations/gitlab-ci.yml).
|
|
249
248
|
|
|
249
|
+
## Built for large monorepos
|
|
250
|
+
|
|
251
|
+
repoglance is designed to stay useful at the scale of a big-tech monorepo —
|
|
252
|
+
millions of lines, thousands of files — not just small projects:
|
|
253
|
+
|
|
254
|
+
- **Diff mode** (`--since <rev>`) analyzes only the files a change touches, so a
|
|
255
|
+
PR check on a giant repo stays fast regardless of total size.
|
|
256
|
+
- **Incremental cache** (`--cache`) reuses unchanged files by mtime + size —
|
|
257
|
+
repeat runs are near-instant (see below).
|
|
258
|
+
- **True multicore scanning**: for large repos it automatically uses a process
|
|
259
|
+
pool (complexity parsing is CPU-bound, so threads alone can't use every core),
|
|
260
|
+
roughly halving cold-scan time. Force it with `--processes` / `--threads`.
|
|
261
|
+
- **`.gitignore`-aware** and **vendored/generated exclusion**, so third-party
|
|
262
|
+
and generated code doesn't drown the signal.
|
|
263
|
+
- **Path scoping** with `--include` / `--exclude` globs for per-team slices of a
|
|
264
|
+
shared repo.
|
|
265
|
+
|
|
266
|
+
> Note: repoglance is an independent open-source project. It is not affiliated
|
|
267
|
+
> with, endorsed by, or used by any company named for scale comparison.
|
|
268
|
+
|
|
269
|
+
## Performance
|
|
270
|
+
|
|
271
|
+
Measured on Django (3,180 files, ~415k lines of code), single machine:
|
|
272
|
+
|
|
273
|
+
| Run | Time |
|
|
274
|
+
|---|--:|
|
|
275
|
+
| Cold scan, threads | ~20.5 s |
|
|
276
|
+
| Cold scan, process pool (auto on large repos) | ~10.1 s |
|
|
277
|
+
| Re-run with `--cache` | ~1.1 s |
|
|
278
|
+
|
|
279
|
+
The cold scan is dominated by real per-function complexity parsing. The process
|
|
280
|
+
pool spreads that across cores (~2× here); the incremental cache (`--cache
|
|
281
|
+
<file>`) reuses unchanged files by mtime + size, so repeat runs — the common
|
|
282
|
+
case in editors and CI — are roughly **20× faster** than a cold thread scan.
|
|
283
|
+
|
|
250
284
|
## Design goals
|
|
251
285
|
|
|
252
286
|
- **Fast** — a single pass, no external services.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Reproducible timing for repoglance.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python benchmarks/bench.py <path> [--cache]
|
|
6
|
+
|
|
7
|
+
Prints files, lines of code, and wall-clock time for a scan. Pass --cache to
|
|
8
|
+
measure a warm run through the incremental cache.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(target: str, use_cache: bool) -> None:
|
|
21
|
+
args = [sys.executable, "-m", "repoglance", target, "--no-git", "--json"]
|
|
22
|
+
cache_file = None
|
|
23
|
+
if use_cache:
|
|
24
|
+
cache_file = str(Path(tempfile.gettempdir()) / "repoglance-bench.cache")
|
|
25
|
+
args += ["--cache", cache_file]
|
|
26
|
+
subprocess.run(args, capture_output=True) # warm the cache first
|
|
27
|
+
|
|
28
|
+
start = time.time()
|
|
29
|
+
proc = subprocess.run(args, capture_output=True, text=True)
|
|
30
|
+
elapsed = time.time() - start
|
|
31
|
+
|
|
32
|
+
data = json.loads(proc.stdout)
|
|
33
|
+
loc = data["total_code_lines"]
|
|
34
|
+
files = data["total_files"]
|
|
35
|
+
rate = loc / elapsed if elapsed else 0
|
|
36
|
+
label = "cached" if use_cache else "cold"
|
|
37
|
+
print(f"{target}: {files} files, {loc:,} loc — {elapsed:.2f}s ({label}, {rate:,.0f} loc/s)")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
if len(sys.argv) < 2:
|
|
42
|
+
print(__doc__)
|
|
43
|
+
raise SystemExit(1)
|
|
44
|
+
path = sys.argv[1]
|
|
45
|
+
run(path, use_cache="--cache" in sys.argv[2:])
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "repoglance"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.2"
|
|
8
8
|
description = "Instant, gorgeous insight into any code repository — languages, complexity hotspots, TODOs and git activity in one terminal command."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -39,10 +39,18 @@ Issues = "https://github.com/SRJ-ai/repoglance/issues"
|
|
|
39
39
|
repoglance = "repoglance.cli:main"
|
|
40
40
|
|
|
41
41
|
[project.optional-dependencies]
|
|
42
|
-
dev = ["pytest>=7.0", "pytest-cov"]
|
|
42
|
+
dev = ["pytest>=7.0", "pytest-cov", "ruff>=0.4"]
|
|
43
43
|
|
|
44
44
|
[tool.hatch.build.targets.wheel]
|
|
45
45
|
packages = ["src/repoglance"]
|
|
46
46
|
|
|
47
47
|
[tool.pytest.ini_options]
|
|
48
48
|
testpaths = ["tests"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
target-version = "py39"
|
|
52
|
+
line-length = 100
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "W", "I", "B", "C4", "SIM"]
|
|
56
|
+
ignore = ["E501", "SIM105", "B008"]
|
|
@@ -54,6 +54,6 @@ def rebuild(rel: str, entry: dict):
|
|
|
54
54
|
"""Reconstruct (FileStat, todos, scores, vendored) from a cache entry."""
|
|
55
55
|
lang, lines, code, blank, comment = entry["stat"]
|
|
56
56
|
stat = FileStat(rel, lang, lines, code, blank, comment, entry["size"])
|
|
57
|
-
todos = [Todo(rel,
|
|
57
|
+
todos = [Todo(rel, ln, m, t) for ln, m, t in entry["todos"]]
|
|
58
58
|
scores = [FuncScore(rel, n, ln, cx, nloc, tok, pr) for n, ln, cx, nloc, tok, pr in entry["scores"]]
|
|
59
59
|
return stat, todos, scores, entry.get("vendored", False)
|
|
@@ -8,9 +8,8 @@ from pathlib import Path
|
|
|
8
8
|
import click
|
|
9
9
|
from rich.console import Console
|
|
10
10
|
|
|
11
|
-
from . import __version__
|
|
11
|
+
from . import __version__, gitinfo, report
|
|
12
12
|
from . import badge as badgemod
|
|
13
|
-
from . import gitinfo, report
|
|
14
13
|
from .config import load_config
|
|
15
14
|
from .scanner import changed_files, scan
|
|
16
15
|
|
|
@@ -26,6 +25,7 @@ def _resolve(cli_val, cfg, key, default):
|
|
|
26
25
|
@click.option("--md", "--markdown", "as_md", is_flag=True, help="Emit a Markdown report (PR comments / summaries).")
|
|
27
26
|
@click.option("--csv", "as_csv", is_flag=True, help="Emit a per-file CSV.")
|
|
28
27
|
@click.option("--sarif", "as_sarif", is_flag=True, help="Emit SARIF 2.1.0 (GitHub code scanning).")
|
|
28
|
+
@click.option("--sarif-threshold", type=int, default=10, show_default=True, help="Minimum complexity reported in SARIF.")
|
|
29
29
|
# file artifacts
|
|
30
30
|
@click.option("--html", "html_path", type=click.Path(dir_okay=False, path_type=Path), help="Write a standalone HTML report.")
|
|
31
31
|
@click.option("--svg", "svg_path", type=click.Path(dir_okay=False, path_type=Path), help="Write a standalone SVG report.")
|
|
@@ -39,7 +39,8 @@ def _resolve(cli_val, cfg, key, default):
|
|
|
39
39
|
@click.option("--ignore", multiple=True, help="Extra directory name to ignore (repeatable).")
|
|
40
40
|
@click.option("--no-git", is_flag=True, help="Skip git history analysis.")
|
|
41
41
|
@click.option("--max-bytes", type=int, default=None, help="Skip files larger than this many bytes.")
|
|
42
|
-
@click.option("--jobs", type=int, default=None, help="
|
|
42
|
+
@click.option("--jobs", type=int, default=None, help="Number of parallel workers for scanning.")
|
|
43
|
+
@click.option("--processes/--threads", "use_processes", default=None, help="Force process- or thread-based parallelism (default: auto by repo size).")
|
|
43
44
|
@click.option("--duplicates", "want_dupes", is_flag=True, help="Detect duplicated code blocks.")
|
|
44
45
|
@click.option("--owners", "want_owners", is_flag=True, help="Attribute top complexity hotspots to authors (git blame).")
|
|
45
46
|
@click.option("--include-vendored", is_flag=True, help="Analyze vendored/generated files instead of excluding them.")
|
|
@@ -54,10 +55,11 @@ def _resolve(cli_val, cfg, key, default):
|
|
|
54
55
|
@click.option("--max-todos", type=int, default=None, help="Fail (with --ci) if TODO markers exceed this count.")
|
|
55
56
|
@click.option("--fail-under", type=int, default=None, help="Fail (with --ci) if the health score is below this (0-100).")
|
|
56
57
|
@click.version_option(__version__, "-V", "--version", prog_name="repoglance")
|
|
57
|
-
def main(path, as_json, as_md, as_csv, as_sarif, html_path, svg_path,
|
|
58
|
-
badge_json_path, baseline_out, since_rev, include, exclude, ignore,
|
|
59
|
-
max_bytes, jobs, want_dupes, want_owners, include_vendored,
|
|
60
|
-
compare_to, fail_on_regression, ci, max_complexity, max_todos,
|
|
58
|
+
def main(path, as_json, as_md, as_csv, as_sarif, sarif_threshold, html_path, svg_path,
|
|
59
|
+
badge_path, badge_json_path, baseline_out, since_rev, include, exclude, ignore,
|
|
60
|
+
no_git, max_bytes, jobs, use_processes, want_dupes, want_owners, include_vendored,
|
|
61
|
+
cache_path, watch, compare_to, fail_on_regression, ci, max_complexity, max_todos,
|
|
62
|
+
fail_under):
|
|
61
63
|
"""Instant, gorgeous insight into any code repository.
|
|
62
64
|
|
|
63
65
|
PATH defaults to the current directory. Configuration may be supplied via
|
|
@@ -76,11 +78,12 @@ def main(path, as_json, as_md, as_csv, as_sarif, html_path, svg_path, badge_path
|
|
|
76
78
|
click.echo(f"No changed files since {since_rev} (or not a git repo).", err=True)
|
|
77
79
|
return
|
|
78
80
|
|
|
79
|
-
scan_kwargs =
|
|
80
|
-
max_bytes
|
|
81
|
-
changed_only
|
|
82
|
-
keep_contents
|
|
83
|
-
|
|
81
|
+
scan_kwargs = {
|
|
82
|
+
"max_bytes": max_bytes, "extra_ignores": ignores, "include": include,
|
|
83
|
+
"exclude": exclude, "changed_only": changed, "jobs": jobs,
|
|
84
|
+
"include_vendored": include_vendored, "keep_contents": want_dupes,
|
|
85
|
+
"processes": use_processes,
|
|
86
|
+
}
|
|
84
87
|
|
|
85
88
|
if watch:
|
|
86
89
|
_run_watch(root, scan_kwargs, no_git)
|
|
@@ -105,7 +108,7 @@ def main(path, as_json, as_md, as_csv, as_sarif, html_path, svg_path, badge_path
|
|
|
105
108
|
max_complexity, max_todos, fail_under):
|
|
106
109
|
sys.exit(2)
|
|
107
110
|
|
|
108
|
-
if _emit_format(result, git_stats, as_json, as_md, as_csv, as_sarif):
|
|
111
|
+
if _emit_format(result, git_stats, as_json, as_md, as_csv, as_sarif, sarif_threshold):
|
|
109
112
|
return
|
|
110
113
|
|
|
111
114
|
# If only artifacts/gates were requested (non-interactive), stop here.
|
|
@@ -205,7 +208,7 @@ def _evaluate(result, git_stats, compare_to, fail_on_regression, ci, cfg,
|
|
|
205
208
|
return failed
|
|
206
209
|
|
|
207
210
|
|
|
208
|
-
def _emit_format(result, git_stats, as_json, as_md, as_csv, as_sarif) -> bool:
|
|
211
|
+
def _emit_format(result, git_stats, as_json, as_md, as_csv, as_sarif, sarif_threshold) -> bool:
|
|
209
212
|
"""Emit a requested stdout format. Returns True if one was emitted."""
|
|
210
213
|
if as_json:
|
|
211
214
|
click.echo(report.to_json(result, git_stats))
|
|
@@ -214,7 +217,7 @@ def _emit_format(result, git_stats, as_json, as_md, as_csv, as_sarif) -> bool:
|
|
|
214
217
|
elif as_csv:
|
|
215
218
|
click.echo(report.to_csv(result), nl=False)
|
|
216
219
|
elif as_sarif:
|
|
217
|
-
click.echo(report.to_sarif(result))
|
|
220
|
+
click.echo(report.to_sarif(result, min_complexity=sarif_threshold))
|
|
218
221
|
else:
|
|
219
222
|
return False
|
|
220
223
|
return True
|
|
@@ -53,6 +53,16 @@ def owners(root: Path, paths: List[str]) -> dict:
|
|
|
53
53
|
return result
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def _tally(raw: Optional[str]) -> list:
|
|
57
|
+
"""Count non-empty lines and return (value, count) pairs, most frequent first."""
|
|
58
|
+
counts: dict = {}
|
|
59
|
+
for line in (raw or "").splitlines():
|
|
60
|
+
key = line.strip()
|
|
61
|
+
if key:
|
|
62
|
+
counts[key] = counts.get(key, 0) + 1
|
|
63
|
+
return sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
|
|
64
|
+
|
|
65
|
+
|
|
56
66
|
def collect(root: Path) -> Optional[GitStats]:
|
|
57
67
|
"""Return git statistics, or ``None`` if ``root`` is not a git repo."""
|
|
58
68
|
if _run(root, ["rev-parse", "--is-inside-work-tree"]) is None:
|
|
@@ -63,20 +73,8 @@ def collect(root: Path) -> Optional[GitStats]:
|
|
|
63
73
|
if total_commits == 0:
|
|
64
74
|
return None
|
|
65
75
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
name = line.strip()
|
|
69
|
-
if name:
|
|
70
|
-
authors[name] = authors.get(name, 0) + 1
|
|
71
|
-
contributors = sorted(authors.items(), key=lambda kv: kv[1], reverse=True)
|
|
72
|
-
|
|
73
|
-
files: dict = {}
|
|
74
|
-
for line in (_run(root, ["log", "--name-only", "--format="]) or "").splitlines():
|
|
75
|
-
p = line.strip()
|
|
76
|
-
if p:
|
|
77
|
-
files[p] = files.get(p, 0) + 1
|
|
78
|
-
hot_files = sorted(files.items(), key=lambda kv: kv[1], reverse=True)
|
|
79
|
-
|
|
76
|
+
contributors = _tally(_run(root, ["log", "--format=%an"]))
|
|
77
|
+
hot_files = _tally(_run(root, ["log", "--name-only", "--format="]))
|
|
80
78
|
days = {
|
|
81
79
|
line.strip()
|
|
82
80
|
for line in (_run(root, ["log", "--format=%cd", "--date=short"]) or "").splitlines()
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
-
from pathlib import Path
|
|
6
5
|
from typing import Optional
|
|
7
6
|
|
|
8
7
|
from rich.columns import Columns
|
|
@@ -211,7 +210,7 @@ def _duplicates_panel(res: ScanResult) -> Panel:
|
|
|
211
210
|
body = Text("No significant duplication found.", style="dim")
|
|
212
211
|
else:
|
|
213
212
|
for b in dd.blocks[:6]:
|
|
214
|
-
locs = ", ".join(f"{p}:{
|
|
213
|
+
locs = ", ".join(f"{p}:{ln}" for p, ln in b.occurrences[:3])
|
|
215
214
|
more = f" +{len(b.occurrences) - 3}" if len(b.occurrences) > 3 else ""
|
|
216
215
|
table.add_row(Text(f"x{len(b.occurrences)}", style="bold red"),
|
|
217
216
|
Text(f"{locs}{more}", style="dim"))
|
|
@@ -379,7 +378,7 @@ def build_payload(res: ScanResult, git: Optional[GitStats]) -> dict:
|
|
|
379
378
|
"ratio": round(dd.ratio, 4),
|
|
380
379
|
"duplicated_lines": dd.duplicated_lines,
|
|
381
380
|
"blocks": [
|
|
382
|
-
{"lines": b.lines, "occurrences": [{"path": p, "line":
|
|
381
|
+
{"lines": b.lines, "occurrences": [{"path": p, "line": ln} for p, ln in b.occurrences]}
|
|
383
382
|
for b in dd.blocks
|
|
384
383
|
],
|
|
385
384
|
}
|
|
@@ -8,9 +8,10 @@ so every file is read exactly once.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import fnmatch
|
|
11
|
+
import functools
|
|
11
12
|
import os
|
|
12
13
|
import subprocess
|
|
13
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
|
14
15
|
from dataclasses import dataclass, field
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import Dict, Iterable, List, Optional
|
|
@@ -19,6 +20,10 @@ from .complexity import FuncScore, analyze_complexity
|
|
|
19
20
|
from .languages import lang_for
|
|
20
21
|
from .vendored import is_vendored
|
|
21
22
|
|
|
23
|
+
# Above this many files we default to process-based parallelism: complexity
|
|
24
|
+
# parsing is CPU-bound and the GIL prevents threads from using multiple cores.
|
|
25
|
+
_PROCESS_THRESHOLD = 400
|
|
26
|
+
|
|
22
27
|
# Directories never worth scanning (used only on the non-git fallback path).
|
|
23
28
|
IGNORE_DIRS = {
|
|
24
29
|
".git", ".hg", ".svn", "node_modules", "venv", ".venv", "env",
|
|
@@ -184,9 +189,7 @@ def _glob_ok(rel: str, include, exclude) -> bool:
|
|
|
184
189
|
"""Apply include/exclude glob patterns (fnmatch) to a repo-relative path."""
|
|
185
190
|
if exclude and any(fnmatch.fnmatch(rel, p) for p in exclude):
|
|
186
191
|
return False
|
|
187
|
-
|
|
188
|
-
return False
|
|
189
|
-
return True
|
|
192
|
+
return not (include and not any(fnmatch.fnmatch(rel, p) for p in include))
|
|
190
193
|
|
|
191
194
|
|
|
192
195
|
def scan(
|
|
@@ -200,6 +203,7 @@ def scan(
|
|
|
200
203
|
include_vendored: bool = False,
|
|
201
204
|
keep_contents: bool = False,
|
|
202
205
|
cache_data: Optional[dict] = None,
|
|
206
|
+
processes: Optional[bool] = None,
|
|
203
207
|
) -> ScanResult:
|
|
204
208
|
"""Walk ``root`` and gather per-file line/language/complexity stats.
|
|
205
209
|
|
|
@@ -246,9 +250,7 @@ def scan(
|
|
|
246
250
|
else:
|
|
247
251
|
to_parse = todo
|
|
248
252
|
|
|
249
|
-
|
|
250
|
-
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
251
|
-
processed = list(pool.map(lambda p: _process_file(p, root_path, max_bytes), to_parse))
|
|
253
|
+
processed = _parallel_process(to_parse, root_path, max_bytes, keep_contents, jobs, processes)
|
|
252
254
|
|
|
253
255
|
for full, item in zip(to_parse, processed):
|
|
254
256
|
if item is None:
|
|
@@ -301,8 +303,33 @@ def _absorb(result, stat, todos, scores, vendored, include_vendored, text, keep_
|
|
|
301
303
|
result.contents[stat.path] = text
|
|
302
304
|
|
|
303
305
|
|
|
304
|
-
def
|
|
305
|
-
"""
|
|
306
|
+
def _parallel_process(to_parse, root_path, max_bytes, keep_contents, jobs, processes):
|
|
307
|
+
"""Analyze files in parallel. Uses processes for large, CPU-bound scans so
|
|
308
|
+
complexity parsing actually spreads across cores (threads can't, due to the
|
|
309
|
+
GIL); falls back to threads for small scans or when contents must be kept."""
|
|
310
|
+
if not to_parse:
|
|
311
|
+
return []
|
|
312
|
+
worker = functools.partial(
|
|
313
|
+
_process_file, root_path=root_path, max_bytes=max_bytes, keep_contents=keep_contents,
|
|
314
|
+
)
|
|
315
|
+
use_proc = processes if processes is not None else (
|
|
316
|
+
not keep_contents and len(to_parse) >= _PROCESS_THRESHOLD and (os.cpu_count() or 1) > 1
|
|
317
|
+
)
|
|
318
|
+
if use_proc:
|
|
319
|
+
try:
|
|
320
|
+
workers = jobs or min(8, os.cpu_count() or 2)
|
|
321
|
+
with ProcessPoolExecutor(max_workers=workers) as pool:
|
|
322
|
+
return list(pool.map(worker, to_parse, chunksize=16))
|
|
323
|
+
except Exception:
|
|
324
|
+
pass # fall back to threads if the process pool cannot start
|
|
325
|
+
workers = jobs or min(32, (os.cpu_count() or 4) * 4)
|
|
326
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
327
|
+
return list(pool.map(worker, to_parse))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _process_file(full: Path, root_path: Path, max_bytes: int, keep_contents: bool = False):
|
|
331
|
+
"""Read and analyze one file. Returns (FileStat, todos, scores, text, vendored)
|
|
332
|
+
or None. Top-level and picklable so it works with a process pool."""
|
|
306
333
|
fn = full.name
|
|
307
334
|
ext = fn.rsplit(".", 1)[-1] if "." in fn else ""
|
|
308
335
|
language = lang_for(ext, fn)
|
|
@@ -323,7 +350,8 @@ def _process_file(full: Path, root_path: Path, max_bytes: int):
|
|
|
323
350
|
if not vendored:
|
|
324
351
|
_collect_todos(text, rel, language, todos)
|
|
325
352
|
_score_complexity(text, rel, language, scores)
|
|
326
|
-
|
|
353
|
+
# Avoid shipping file text back across a process boundary unless needed.
|
|
354
|
+
return stat, todos, scores, (text if keep_contents else None), vendored
|
|
327
355
|
|
|
328
356
|
|
|
329
357
|
def _walk(root_path: Path, ignore: set):
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from click.testing import CliRunner
|
|
4
|
+
|
|
5
|
+
from repoglance import gitinfo, plugins
|
|
6
|
+
from repoglance.cli import main
|
|
7
|
+
from repoglance.config import load_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_process_and_thread_modes_agree(tmp_path):
|
|
11
|
+
from repoglance.scanner import scan
|
|
12
|
+
|
|
13
|
+
for i in range(5):
|
|
14
|
+
(tmp_path / f"m{i}.py").write_text(
|
|
15
|
+
f"def f{i}(x):\n if x:\n return {i}\n return 0\n", encoding="utf-8"
|
|
16
|
+
)
|
|
17
|
+
threaded = scan(tmp_path, processes=False)
|
|
18
|
+
forked = scan(tmp_path, processes=True)
|
|
19
|
+
assert [f.path for f in threaded.files] == [f.path for f in forked.files]
|
|
20
|
+
assert threaded.total_code == forked.total_code
|
|
21
|
+
assert {s.name for s in threaded.func_scores} == {s.name for s in forked.func_scores}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_tally_counts_and_orders():
|
|
25
|
+
pairs = gitinfo._tally("alice\nbob\nalice\n\nalice\n")
|
|
26
|
+
assert pairs[0] == ("alice", 3)
|
|
27
|
+
assert ("bob", 1) in pairs
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_config_dedicated_overrides_pyproject(tmp_path):
|
|
31
|
+
(tmp_path / "pyproject.toml").write_text(
|
|
32
|
+
"[tool.repoglance]\nfail_under = 50\nmax_complexity = 10\n", encoding="utf-8"
|
|
33
|
+
)
|
|
34
|
+
(tmp_path / ".repoglance.toml").write_text("fail_under = 90\n", encoding="utf-8")
|
|
35
|
+
cfg = load_config(tmp_path)
|
|
36
|
+
assert cfg["fail_under"] == 90 # dedicated file wins
|
|
37
|
+
assert cfg["max_complexity"] == 10 # pyproject value retained
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_sarif_threshold_filters(tmp_path):
|
|
41
|
+
(tmp_path / "a.py").write_text(
|
|
42
|
+
"def f(x):\n"
|
|
43
|
+
+ "".join(f" if x == {i}:\n return {i}\n" for i in range(8))
|
|
44
|
+
+ " return 0\n",
|
|
45
|
+
encoding="utf-8",
|
|
46
|
+
)
|
|
47
|
+
low = CliRunner().invoke(main, [str(tmp_path), "--no-git", "--sarif", "--sarif-threshold", "1"])
|
|
48
|
+
high = CliRunner().invoke(main, [str(tmp_path), "--no-git", "--sarif", "--sarif-threshold", "100"])
|
|
49
|
+
n_low = len(json.loads(low.output)["runs"][0]["results"])
|
|
50
|
+
n_high = len(json.loads(high.output)["runs"][0]["results"])
|
|
51
|
+
assert n_low >= 1
|
|
52
|
+
assert n_high == 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_broken_plugin_is_isolated(monkeypatch):
|
|
56
|
+
class FakeEP:
|
|
57
|
+
name = "boom"
|
|
58
|
+
|
|
59
|
+
def load(self):
|
|
60
|
+
def _f(_result):
|
|
61
|
+
raise RuntimeError("kaboom")
|
|
62
|
+
return _f
|
|
63
|
+
|
|
64
|
+
monkeypatch.setattr(plugins, "_iter_entry_points", lambda: [FakeEP()])
|
|
65
|
+
out = plugins.run_plugins(object())
|
|
66
|
+
assert "boom" in out
|
|
67
|
+
assert "error" in out["boom"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_working_plugin_value(monkeypatch):
|
|
71
|
+
class FakeEP:
|
|
72
|
+
name = "answer"
|
|
73
|
+
|
|
74
|
+
def load(self):
|
|
75
|
+
return lambda _result: 42
|
|
76
|
+
|
|
77
|
+
monkeypatch.setattr(plugins, "_iter_entry_points", lambda: [FakeEP()])
|
|
78
|
+
assert plugins.run_plugins(object()) == {"answer": 42}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|