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.
Files changed (58) hide show
  1. {repoglance-0.4.0 → repoglance-0.4.2}/.github/workflows/ci.yml +3 -1
  2. {repoglance-0.4.0 → repoglance-0.4.2}/CHANGELOG.md +17 -0
  3. {repoglance-0.4.0 → repoglance-0.4.2}/CONTRIBUTING.md +2 -1
  4. {repoglance-0.4.0 → repoglance-0.4.2}/PKG-INFO +39 -4
  5. {repoglance-0.4.0 → repoglance-0.4.2}/README.md +37 -3
  6. repoglance-0.4.2/benchmarks/bench.py +45 -0
  7. {repoglance-0.4.0 → repoglance-0.4.2}/pyproject.toml +10 -2
  8. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/__init__.py +1 -1
  9. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/cache.py +1 -1
  10. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/cli.py +18 -15
  11. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/gitinfo.py +12 -14
  12. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/report.py +2 -3
  13. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/scanner.py +38 -10
  14. {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_complexity.py +1 -1
  15. repoglance-0.4.2/tests/test_v041.py +78 -0
  16. {repoglance-0.4.0 → repoglance-0.4.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {repoglance-0.4.0 → repoglance-0.4.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  18. {repoglance-0.4.0 → repoglance-0.4.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  19. {repoglance-0.4.0 → repoglance-0.4.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  20. {repoglance-0.4.0 → repoglance-0.4.2}/.github/workflows/release.yml +0 -0
  21. {repoglance-0.4.0 → repoglance-0.4.2}/.github/workflows/repoglance.yml +0 -0
  22. {repoglance-0.4.0 → repoglance-0.4.2}/.gitignore +0 -0
  23. {repoglance-0.4.0 → repoglance-0.4.2}/.pre-commit-hooks.yaml +0 -0
  24. {repoglance-0.4.0 → repoglance-0.4.2}/.repoglance-badge.json +0 -0
  25. {repoglance-0.4.0 → repoglance-0.4.2}/CODE_OF_CONDUCT.md +0 -0
  26. {repoglance-0.4.0 → repoglance-0.4.2}/Dockerfile +0 -0
  27. {repoglance-0.4.0 → repoglance-0.4.2}/LICENSE +0 -0
  28. {repoglance-0.4.0 → repoglance-0.4.2}/RELEASING.md +0 -0
  29. {repoglance-0.4.0 → repoglance-0.4.2}/SECURITY.md +0 -0
  30. {repoglance-0.4.0 → repoglance-0.4.2}/action.yml +0 -0
  31. {repoglance-0.4.0 → repoglance-0.4.2}/assets/badge.svg +0 -0
  32. {repoglance-0.4.0 → repoglance-0.4.2}/assets/demo.svg +0 -0
  33. {repoglance-0.4.0 → repoglance-0.4.2}/assets/demo.tape +0 -0
  34. {repoglance-0.4.0 → repoglance-0.4.2}/assets/showcase/flask.svg +0 -0
  35. {repoglance-0.4.0 → repoglance-0.4.2}/assets/showcase/httpie.svg +0 -0
  36. {repoglance-0.4.0 → repoglance-0.4.2}/assets/showcase/requests.svg +0 -0
  37. {repoglance-0.4.0 → repoglance-0.4.2}/docs/index.md +0 -0
  38. {repoglance-0.4.0 → repoglance-0.4.2}/editors/vscode/README.md +0 -0
  39. {repoglance-0.4.0 → repoglance-0.4.2}/editors/vscode/extension.js +0 -0
  40. {repoglance-0.4.0 → repoglance-0.4.2}/editors/vscode/package.json +0 -0
  41. {repoglance-0.4.0 → repoglance-0.4.2}/integrations/gitlab-ci.yml +0 -0
  42. {repoglance-0.4.0 → repoglance-0.4.2}/mkdocs.yml +0 -0
  43. {repoglance-0.4.0 → repoglance-0.4.2}/packaging/homebrew/repoglance.rb +0 -0
  44. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/__main__.py +0 -0
  45. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/badge.py +0 -0
  46. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/complexity.py +0 -0
  47. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/config.py +0 -0
  48. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/dedup.py +0 -0
  49. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/languages.py +0 -0
  50. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/metrics.py +0 -0
  51. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/plugins.py +0 -0
  52. {repoglance-0.4.0 → repoglance-0.4.2}/src/repoglance/vendored.py +0 -0
  53. {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_cli.py +0 -0
  54. {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_features.py +0 -0
  55. {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_scanner.py +0 -0
  56. {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_scanner_extra.py +0 -0
  57. {repoglance-0.4.0 → repoglance-0.4.2}/tests/test_v030.py +0 -0
  58. {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 to `rich` + `click`. Anything heavier needs a
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.0
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
  [![PyPI](https://img.shields.io/pypi/v/repoglance.svg)](https://pypi.org/project/repoglance/)
43
- [![Downloads](https://img.shields.io/pypi/dm/repoglance.svg)](https://pypi.org/project/repoglance/)
44
44
  [![CI](https://github.com/SRJ-ai/repoglance/actions/workflows/ci.yml/badge.svg)](https://github.com/SRJ-ai/repoglance/actions/workflows/ci.yml)
45
45
  [![Python](https://img.shields.io/pypi/pyversions/repoglance.svg)](https://pypi.org/project/repoglance/)
46
46
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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@main
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.2.2
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
  [![PyPI](https://img.shields.io/pypi/v/repoglance.svg)](https://pypi.org/project/repoglance/)
12
- [![Downloads](https://img.shields.io/pypi/dm/repoglance.svg)](https://pypi.org/project/repoglance/)
13
12
  [![CI](https://github.com/SRJ-ai/repoglance/actions/workflows/ci.yml/badge.svg)](https://github.com/SRJ-ai/repoglance/actions/workflows/ci.yml)
14
13
  [![Python](https://img.shields.io/pypi/pyversions/repoglance.svg)](https://pypi.org/project/repoglance/)
15
14
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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@main
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.2.2
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.0"
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"]
@@ -1,3 +1,3 @@
1
1
  """repoglance — instant, gorgeous insight into any code repository."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.2"
@@ -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, l, m, t) for l, m, t in entry["todos"]]
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="Worker threads for scanning.")
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, badge_path,
58
- badge_json_path, baseline_out, since_rev, include, exclude, ignore, no_git,
59
- max_bytes, jobs, want_dupes, want_owners, include_vendored, cache_path, watch,
60
- compare_to, fail_on_regression, ci, max_complexity, max_todos, fail_under):
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 = dict(
80
- max_bytes=max_bytes, extra_ignores=ignores, include=include, exclude=exclude,
81
- changed_only=changed, jobs=jobs, include_vendored=include_vendored,
82
- keep_contents=want_dupes,
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
- authors: dict = {}
67
- for line in (_run(root, ["log", "--format=%an"]) or "").splitlines():
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}:{l}" for p, l in b.occurrences[:3])
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": l} for p, l in b.occurrences]}
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
- if include and not any(fnmatch.fnmatch(rel, p) for p in include):
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
- workers = jobs or min(32, (os.cpu_count() or 4) * 4)
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 _process_file(full: Path, root_path: Path, max_bytes: int):
305
- """Read and analyze one file. Returns (FileStat, todos, scores) or None."""
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
- return stat, todos, scores, text, vendored
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):
@@ -1,4 +1,4 @@
1
- from repoglance.complexity import python_complexity, heuristic_complexity
1
+ from repoglance.complexity import heuristic_complexity, python_complexity
2
2
 
3
3
 
4
4
  def test_straight_line_is_one():
@@ -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