gitmole 0.4.0__tar.gz → 0.6.0__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 (56) hide show
  1. gitmole-0.6.0/PKG-INFO +159 -0
  2. gitmole-0.6.0/README.md +139 -0
  3. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/__init__.py +1 -1
  4. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/filetypes.py +9 -0
  5. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/findings.py +41 -15
  6. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/leaks.py +20 -7
  7. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/load.py +3 -0
  8. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/render.py +43 -10
  9. gitmole-0.6.0/gitmole.egg-info/PKG-INFO +159 -0
  10. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_filetypes.py +6 -0
  11. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_findings.py +42 -1
  12. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_leaks.py +25 -1
  13. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_load.py +13 -0
  14. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_render.py +42 -0
  15. gitmole-0.4.0/PKG-INFO +0 -470
  16. gitmole-0.4.0/README.md +0 -450
  17. gitmole-0.4.0/gitmole.egg-info/PKG-INFO +0 -470
  18. {gitmole-0.4.0 → gitmole-0.6.0}/LICENSE +0 -0
  19. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/__main__.py +0 -0
  20. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/backtest.py +0 -0
  21. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/banner.py +0 -0
  22. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/blame.py +0 -0
  23. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/cli.py +0 -0
  24. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/functions.py +0 -0
  25. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/hotspots.py +0 -0
  26. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/identity.py +0 -0
  27. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/knowledge.py +0 -0
  28. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/loss.py +0 -0
  29. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/maat.py +0 -0
  30. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/run.py +0 -0
  31. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/textfmt.py +0 -0
  32. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/trend.py +0 -0
  33. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/watch.py +0 -0
  34. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole.egg-info/SOURCES.txt +0 -0
  35. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole.egg-info/dependency_links.txt +0 -0
  36. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole.egg-info/entry_points.txt +0 -0
  37. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole.egg-info/requires.txt +0 -0
  38. {gitmole-0.4.0 → gitmole-0.6.0}/gitmole.egg-info/top_level.txt +0 -0
  39. {gitmole-0.4.0 → gitmole-0.6.0}/pyproject.toml +0 -0
  40. {gitmole-0.4.0 → gitmole-0.6.0}/setup.cfg +0 -0
  41. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_backtest.py +0 -0
  42. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_banner.py +0 -0
  43. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_blame.py +0 -0
  44. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_cli.py +0 -0
  45. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_functions.py +0 -0
  46. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_golden.py +0 -0
  47. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_hotspots.py +0 -0
  48. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_identity.py +0 -0
  49. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_knowledge.py +0 -0
  50. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_loss.py +0 -0
  51. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_maat.py +0 -0
  52. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_packaging.py +0 -0
  53. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_run.py +0 -0
  54. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_textfmt.py +0 -0
  55. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_trend.py +0 -0
  56. {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_watch.py +0 -0
gitmole-0.6.0/PKG-INFO ADDED
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitmole
3
+ Version: 0.6.0
4
+ Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/antvinni/gitmole
7
+ Keywords: git,analysis,hotspots,code-age,repository,metrics
8
+ Classifier: Environment :: Console
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Software Development :: Version Control :: Git
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: rich>=13
16
+ Requires-Dist: lizard>=1.24
17
+ Provides-Extra: plots
18
+ Requires-Dist: git-of-theseus; extra == "plots"
19
+ Dynamic: license-file
20
+
21
+ <img src="https://raw.githubusercontent.com/antvinni/gitmole/main/docs/banner.svg" width="912" alt="gitmole">
22
+
23
+ # gitmole
24
+
25
+ A toolkit for digging into any cloned git repository: who works on it,
26
+ where the risk is, how old the code is, whether the repo itself is healthy,
27
+ and whether anything sensitive was ever committed.
28
+
29
+ Any stack. Free. Offline. No token. No AI. Light.
30
+
31
+ - **Free.** MIT licence, no paid tier, no account. The tools it runs are open source too.
32
+ - **Any stack.** It reads what every repository has: the git log, git blame and the files themselves.
33
+ - **Offline.** Everything runs against a clone on your machine. Nothing is uploaded, nothing phones home.
34
+ - **No token.** A local clone needs no credentials. The optional `owner/repo` shortcut uses the `gh` login you already have, and you ask for it.
35
+ - **No AI.** Every finding is a plain rule over counts you can recompute by hand. The same clone gives the same report every time.
36
+ - **Light.** A 4,400-commit repository takes under thirty seconds. A few thousand lines of Python plus two libraries.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ # macOS, or Linux with Homebrew: gitmole and the three tools it runs
42
+ brew tap antvinni/gitmole https://github.com/antvinni/gitmole
43
+ brew trust antvinni/gitmole
44
+ brew install gitmole
45
+
46
+ # anywhere else: scc, git-sizer and betterleaks on your PATH, then
47
+ pipx install gitmole
48
+ ```
49
+
50
+ Linux package names, the release binaries, `--plots` and the pip caveats:
51
+ [docs/install.md](https://github.com/antvinni/gitmole/blob/main/docs/install.md).
52
+
53
+ ## Usage
54
+
55
+ ```bash
56
+ gitmole . # the clone you are in
57
+ gitmole /path/to/clone # any local clone
58
+ gitmole owner/repo # clones with gh into a temp dir first
59
+ gitmole 'owner/*' # every non-archived repo of a user or org, one summary table
60
+
61
+ gitmole . --markdown report.md # the same report as a Markdown document
62
+ gitmole . --json report.json # every table, the watch list and the findings
63
+ gitmole . --fail-on warning # exit 3 if any finding is a warning or worse
64
+ gitmole . --risk main --risk-threshold 5 # exit 3 if the files changed since main are too risky
65
+ gitmole . --since 2y --full # the current team, every row and column
66
+ ```
67
+
68
+ A CI job that runs `gitmole . --fail-on critical --markdown - >> "$GITHUB_STEP_SUMMARY"`
69
+ blocks on secrets in source files and still posts the report. Every option:
70
+ [docs/cli.md](https://github.com/antvinni/gitmole/blob/main/docs/cli.md).
71
+
72
+ ## What you get
73
+
74
+ Running `gitmole .` inside this repository:
75
+
76
+ ```text
77
+ ╭─ gitmole ────────────────────────────────────────────────────────────────────────────────────────╮
78
+ │ 135 commits · 2026-09-15 → 2026-09-16 · 1 identity · branch main │
79
+ │ 7,002 lines in 44 files · Python, Ruby │
80
+ │ most commits on Wed at 20:00 · 4% of commits are fixes · 100% of surviving code from 2026 │
81
+ │ 3 warnings, 1 note │
82
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
83
+ ╭─ Findings (4) ───────────────────────────────────────────────────────────────────────────────────╮
84
+ │ ▲ Bus factor of one │
85
+ │ vinni wrote 100% of the code that survives today │
86
+ │ ↳ Pair someone with vinni on gitmole/ and build/ first; they are 100% and 100% theirs. │
87
+ │ ▲ Hotspots getting more complex │
88
+ │ 4 of the 10 top source hotspots grew by 25% or more in a year: gitmole/render.py (+160%), │
89
+ │ gitmole/cli.py (+32%), gitmole/findings.py (+266%), gitmole/run.py (+26%) │
90
+ │ ↳ Split gitmole/render.py before the next change; its complexity grew 160% in a year. │
91
+ │ ▲ Knowledge islands │
92
+ │ 2 area(s) with at least 200 lines were written almost entirely by one person: gitmole/ (vinni │
93
+ │ 100%); build/ (vinni 100%). That is 97% of all lines added │
94
+ │ ↳ Pair someone with vinni on gitmole/ first; it is the largest at 5,371 lines. │
95
+ │ ● Bug magnets │
96
+ │ 4 file(s) were fixed 3+ times in the last six months: gitmole/cli.py (3 recent, 3 total); │
97
+ │ gitmole/findings.py (3 recent, 3 total); gitmole/render.py (3 recent, 3 total); gitmole/run.py │
98
+ │ (3 recent, 3 total) │
99
+ │ ↳ Review gitmole/cli.py and gitmole/findings.py before the next release; expect the next bug │
100
+ │ there. │
101
+ │ ✔ No secrets in history │
102
+ │ betterleaks scanned every commit on every branch │
103
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
104
+ ```
105
+
106
+ Below that: a watch list of the five files where the next bug is most
107
+ likely, with the reasons in words and a backtest of how the list would have
108
+ done; then tables for people, the knowledge map, the timeline, hotspots with
109
+ their complexity trend, change coupling, complex functions and repo health.
110
+ The full report is in
111
+ [docs/example.md](https://github.com/antvinni/gitmole/blob/main/docs/example.md),
112
+ and every section is explained in
113
+ [docs/output.md](https://github.com/antvinni/gitmole/blob/main/docs/output.md).
114
+
115
+ ## The tool set
116
+
117
+ One tool per question; together they cover what a single command can tell
118
+ you about a clone.
119
+
120
+ | Question | Tool | Install |
121
+ |---|---|---|
122
+ | What is this repo, at a glance; who commits, when, how much churn | gitmole itself, from the git log | built in |
123
+ | How big is the codebase, per language | [scc](https://github.com/boyter/scc) | brew |
124
+ | Is the repo itself healthy (huge blobs, deep trees) | [git-sizer](https://github.com/github/git-sizer) | brew |
125
+ | Where is the risk: hotspots, coupling, ownership | gitmole's own change analysis over `git log --numstat` | built in |
126
+ | How old is the surviving code, per year and author | gitmole's own blame pass (one `git blame` per file at HEAD) | built in |
127
+ | Code-age and survival plots over time | [git-of-theseus](https://github.com/erikbern/git-of-theseus) | pip, opt-in with `--plots` |
128
+ | Per-function complexity, length, parameters; duplicated blocks with `--duplicates` | [lizard](https://github.com/terryyin/lizard) | pip, installed with gitmole; tracked code files only |
129
+ | Have secrets ever been committed | [betterleaks](https://github.com/betterleaks/betterleaks) | brew |
130
+
131
+ Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/blob/main/docs/tools.md).
132
+
133
+ ## Docs
134
+
135
+ - [Install](https://github.com/antvinni/gitmole/blob/main/docs/install.md): macOS, Linux, pipx, the check, pinned releases.
136
+ - [Command line](https://github.com/antvinni/gitmole/blob/main/docs/cli.md): every option, portfolio mode, exports and CI gates, big repositories.
137
+ - [The report and the output files](https://github.com/antvinni/gitmole/blob/main/docs/output.md): what each section and each file means.
138
+ - [Full example report](https://github.com/antvinni/gitmole/blob/main/docs/example.md): the whole `gitmole .` output for this repository.
139
+ - [Why these tools](https://github.com/antvinni/gitmole/blob/main/docs/tools.md): the rationale, what was left out, licences.
140
+ - [Development](https://github.com/antvinni/gitmole/blob/main/docs/development.md): setup, tests, releases, code layout.
141
+ - [Contributing](https://github.com/antvinni/gitmole/blob/main/CONTRIBUTING.md): bugs, ideas, pull requests, security reports.
142
+
143
+ ## Safety
144
+
145
+ - Everything is offline except the optional clone step, which uses your
146
+ existing gh auth. None of the tools send data anywhere.
147
+ - Remote targets are cloned into a fresh temp directory. Local clones are
148
+ only read, but the log export and the secrets scan touch all branches.
149
+ - Secret values never reach the output directory. betterleaks reports to
150
+ gitmole in memory, and gitmole stores a short keyed hash in place of the
151
+ value, the matched text and the commit message. The key is random, made
152
+ for that one report and never saved.
153
+
154
+ ## License
155
+
156
+ [MIT](https://github.com/antvinni/gitmole/blob/main/LICENSE). gitmole runs
157
+ the tools it wraps as separate processes and bundles none of them; their
158
+ licences are listed in
159
+ [docs/tools.md](https://github.com/antvinni/gitmole/blob/main/docs/tools.md#licences).
@@ -0,0 +1,139 @@
1
+ <img src="https://raw.githubusercontent.com/antvinni/gitmole/main/docs/banner.svg" width="912" alt="gitmole">
2
+
3
+ # gitmole
4
+
5
+ A toolkit for digging into any cloned git repository: who works on it,
6
+ where the risk is, how old the code is, whether the repo itself is healthy,
7
+ and whether anything sensitive was ever committed.
8
+
9
+ Any stack. Free. Offline. No token. No AI. Light.
10
+
11
+ - **Free.** MIT licence, no paid tier, no account. The tools it runs are open source too.
12
+ - **Any stack.** It reads what every repository has: the git log, git blame and the files themselves.
13
+ - **Offline.** Everything runs against a clone on your machine. Nothing is uploaded, nothing phones home.
14
+ - **No token.** A local clone needs no credentials. The optional `owner/repo` shortcut uses the `gh` login you already have, and you ask for it.
15
+ - **No AI.** Every finding is a plain rule over counts you can recompute by hand. The same clone gives the same report every time.
16
+ - **Light.** A 4,400-commit repository takes under thirty seconds. A few thousand lines of Python plus two libraries.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ # macOS, or Linux with Homebrew: gitmole and the three tools it runs
22
+ brew tap antvinni/gitmole https://github.com/antvinni/gitmole
23
+ brew trust antvinni/gitmole
24
+ brew install gitmole
25
+
26
+ # anywhere else: scc, git-sizer and betterleaks on your PATH, then
27
+ pipx install gitmole
28
+ ```
29
+
30
+ Linux package names, the release binaries, `--plots` and the pip caveats:
31
+ [docs/install.md](https://github.com/antvinni/gitmole/blob/main/docs/install.md).
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ gitmole . # the clone you are in
37
+ gitmole /path/to/clone # any local clone
38
+ gitmole owner/repo # clones with gh into a temp dir first
39
+ gitmole 'owner/*' # every non-archived repo of a user or org, one summary table
40
+
41
+ gitmole . --markdown report.md # the same report as a Markdown document
42
+ gitmole . --json report.json # every table, the watch list and the findings
43
+ gitmole . --fail-on warning # exit 3 if any finding is a warning or worse
44
+ gitmole . --risk main --risk-threshold 5 # exit 3 if the files changed since main are too risky
45
+ gitmole . --since 2y --full # the current team, every row and column
46
+ ```
47
+
48
+ A CI job that runs `gitmole . --fail-on critical --markdown - >> "$GITHUB_STEP_SUMMARY"`
49
+ blocks on secrets in source files and still posts the report. Every option:
50
+ [docs/cli.md](https://github.com/antvinni/gitmole/blob/main/docs/cli.md).
51
+
52
+ ## What you get
53
+
54
+ Running `gitmole .` inside this repository:
55
+
56
+ ```text
57
+ ╭─ gitmole ────────────────────────────────────────────────────────────────────────────────────────╮
58
+ │ 135 commits · 2026-09-15 → 2026-09-16 · 1 identity · branch main │
59
+ │ 7,002 lines in 44 files · Python, Ruby │
60
+ │ most commits on Wed at 20:00 · 4% of commits are fixes · 100% of surviving code from 2026 │
61
+ │ 3 warnings, 1 note │
62
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
63
+ ╭─ Findings (4) ───────────────────────────────────────────────────────────────────────────────────╮
64
+ │ ▲ Bus factor of one │
65
+ │ vinni wrote 100% of the code that survives today │
66
+ │ ↳ Pair someone with vinni on gitmole/ and build/ first; they are 100% and 100% theirs. │
67
+ │ ▲ Hotspots getting more complex │
68
+ │ 4 of the 10 top source hotspots grew by 25% or more in a year: gitmole/render.py (+160%), │
69
+ │ gitmole/cli.py (+32%), gitmole/findings.py (+266%), gitmole/run.py (+26%) │
70
+ │ ↳ Split gitmole/render.py before the next change; its complexity grew 160% in a year. │
71
+ │ ▲ Knowledge islands │
72
+ │ 2 area(s) with at least 200 lines were written almost entirely by one person: gitmole/ (vinni │
73
+ │ 100%); build/ (vinni 100%). That is 97% of all lines added │
74
+ │ ↳ Pair someone with vinni on gitmole/ first; it is the largest at 5,371 lines. │
75
+ │ ● Bug magnets │
76
+ │ 4 file(s) were fixed 3+ times in the last six months: gitmole/cli.py (3 recent, 3 total); │
77
+ │ gitmole/findings.py (3 recent, 3 total); gitmole/render.py (3 recent, 3 total); gitmole/run.py │
78
+ │ (3 recent, 3 total) │
79
+ │ ↳ Review gitmole/cli.py and gitmole/findings.py before the next release; expect the next bug │
80
+ │ there. │
81
+ │ ✔ No secrets in history │
82
+ │ betterleaks scanned every commit on every branch │
83
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
84
+ ```
85
+
86
+ Below that: a watch list of the five files where the next bug is most
87
+ likely, with the reasons in words and a backtest of how the list would have
88
+ done; then tables for people, the knowledge map, the timeline, hotspots with
89
+ their complexity trend, change coupling, complex functions and repo health.
90
+ The full report is in
91
+ [docs/example.md](https://github.com/antvinni/gitmole/blob/main/docs/example.md),
92
+ and every section is explained in
93
+ [docs/output.md](https://github.com/antvinni/gitmole/blob/main/docs/output.md).
94
+
95
+ ## The tool set
96
+
97
+ One tool per question; together they cover what a single command can tell
98
+ you about a clone.
99
+
100
+ | Question | Tool | Install |
101
+ |---|---|---|
102
+ | What is this repo, at a glance; who commits, when, how much churn | gitmole itself, from the git log | built in |
103
+ | How big is the codebase, per language | [scc](https://github.com/boyter/scc) | brew |
104
+ | Is the repo itself healthy (huge blobs, deep trees) | [git-sizer](https://github.com/github/git-sizer) | brew |
105
+ | Where is the risk: hotspots, coupling, ownership | gitmole's own change analysis over `git log --numstat` | built in |
106
+ | How old is the surviving code, per year and author | gitmole's own blame pass (one `git blame` per file at HEAD) | built in |
107
+ | Code-age and survival plots over time | [git-of-theseus](https://github.com/erikbern/git-of-theseus) | pip, opt-in with `--plots` |
108
+ | Per-function complexity, length, parameters; duplicated blocks with `--duplicates` | [lizard](https://github.com/terryyin/lizard) | pip, installed with gitmole; tracked code files only |
109
+ | Have secrets ever been committed | [betterleaks](https://github.com/betterleaks/betterleaks) | brew |
110
+
111
+ Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/blob/main/docs/tools.md).
112
+
113
+ ## Docs
114
+
115
+ - [Install](https://github.com/antvinni/gitmole/blob/main/docs/install.md): macOS, Linux, pipx, the check, pinned releases.
116
+ - [Command line](https://github.com/antvinni/gitmole/blob/main/docs/cli.md): every option, portfolio mode, exports and CI gates, big repositories.
117
+ - [The report and the output files](https://github.com/antvinni/gitmole/blob/main/docs/output.md): what each section and each file means.
118
+ - [Full example report](https://github.com/antvinni/gitmole/blob/main/docs/example.md): the whole `gitmole .` output for this repository.
119
+ - [Why these tools](https://github.com/antvinni/gitmole/blob/main/docs/tools.md): the rationale, what was left out, licences.
120
+ - [Development](https://github.com/antvinni/gitmole/blob/main/docs/development.md): setup, tests, releases, code layout.
121
+ - [Contributing](https://github.com/antvinni/gitmole/blob/main/CONTRIBUTING.md): bugs, ideas, pull requests, security reports.
122
+
123
+ ## Safety
124
+
125
+ - Everything is offline except the optional clone step, which uses your
126
+ existing gh auth. None of the tools send data anywhere.
127
+ - Remote targets are cloned into a fresh temp directory. Local clones are
128
+ only read, but the log export and the secrets scan touch all branches.
129
+ - Secret values never reach the output directory. betterleaks reports to
130
+ gitmole in memory, and gitmole stores a short keyed hash in place of the
131
+ value, the matched text and the commit message. The key is random, made
132
+ for that one report and never saved.
133
+
134
+ ## License
135
+
136
+ [MIT](https://github.com/antvinni/gitmole/blob/main/LICENSE). gitmole runs
137
+ the tools it wraps as separate processes and bundles none of them; their
138
+ licences are listed in
139
+ [docs/tools.md](https://github.com/antvinni/gitmole/blob/main/docs/tools.md#licences).
@@ -1,3 +1,3 @@
1
1
  """gitmole: offline git repository analysis with a terminal report."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.6.0"
@@ -56,6 +56,15 @@ def is_test_path(path: str) -> bool:
56
56
  return bool(_TEST_PATH.search(path))
57
57
 
58
58
 
59
+ _DOC_PATH = re.compile(r"(^|/)docs?(/|$)|\.(md|markdown|rst|txt|adoc)$", re.I)
60
+
61
+
62
+ def is_doc_path(path: str) -> bool:
63
+ """Documentation: prose formats anywhere, or anything under docs/. A key in a planning document
64
+ is far more often a template than a leak."""
65
+ return bool(_DOC_PATH.search(path))
66
+
67
+
59
68
  def key(path: str) -> str:
60
69
  """The lowercased extension, or the whole lowercased name when there is none."""
61
70
  name = path.rsplit("/", 1)[-1].lower()
@@ -40,19 +40,23 @@ def _secret_statement(groups: list) -> str:
40
40
 
41
41
  def secrets_found(report: dict) -> list:
42
42
  """Secrets grouped by value. A value anywhere in source is critical; one that only ever appears in
43
- test files (fixtures, saved pages) is a warning, so a critical gate does not trip on test data.
44
- Version strings and shortened tokens were flagged as placeholders and are not a finding."""
43
+ test files (fixtures, saved pages) or documentation (templates, samples) is a warning, so a
44
+ critical gate does not trip on test data or a planning document. Version strings, template markers
45
+ and key blocks without key material were flagged as placeholders and are not a finding."""
45
46
  groups = leaks.group(report.get("secrets") or [])
46
- source = [g for g in groups if not g["test"]]
47
- tests = [g for g in groups if g["test"]]
47
+
48
+ def in_source(g):
49
+ return any(not (filetypes.is_test_path(f) or filetypes.is_doc_path(f)) for f in g["files"])
50
+ source = [g for g in groups if in_source(g)]
51
+ aside = [g for g in groups if not in_source(g)]
48
52
  ignore = "Add the fingerprint of any false positive from secrets.json to .betterleaksignore in the repository."
49
53
  out = []
50
54
  if source:
51
55
  out.append(_f("critical", f"{len(source)} secret(s) in history", _secret_statement(source),
52
56
  f"Rotate them; deleting the file does not remove them from git. {ignore}"))
53
- if tests:
54
- out.append(_f("warning", f"{len(tests)} secret(s) only in test files", _secret_statement(tests),
55
- f"Confirm they are fixtures, not live keys. {ignore}"))
57
+ if aside:
58
+ out.append(_f("warning", f"{len(aside)} secret(s) only in test or documentation files", _secret_statement(aside),
59
+ f"Confirm they are fixtures or templates, not live keys. {ignore}"))
56
60
  return out
57
61
 
58
62
 
@@ -67,15 +71,24 @@ def _all_identities(report: dict):
67
71
  def placeholder_identity(report: dict, min_share: float = 0.01) -> list:
68
72
  """A placeholder name or mailbox with a real share of the commits. One stray commit in thousands
69
73
  is not worth the panel space."""
70
- total = sum(i["commits"] for i in report["meta"].get("identities") or [])
74
+ identities = report["meta"].get("identities") or []
75
+ total = sum(i["commits"] for i in identities)
76
+
77
+ def is_placeholder(i):
78
+ return i["name"].strip().lower() in PLACEHOLDER_NAMES or bool(PLACEHOLDER_EMAIL.search(i["email"].lower()))
79
+ real = max((i for i in identities if not is_placeholder(i)), key=lambda i: i["commits"], default=None)
71
80
  out = []
72
81
  for i in _all_identities(report):
73
82
  if total and i["commits"] / total < min_share:
74
83
  continue
75
- if i["name"].strip().lower() in PLACEHOLDER_NAMES or PLACEHOLDER_EMAIL.search(i["email"].lower()):
84
+ if is_placeholder(i):
85
+ # the busiest real identity is the likely owner; the line is offered, never applied
86
+ advice = (f"Set user.name and user.email. If those commits are {real['name']}'s, add to .mailmap: "
87
+ f"{real['name']} <{real['email']}> {i['name']} <{i['email']}>; the people, bus factor and "
88
+ f"knowledge findings then describe one person." if real
89
+ else "Set user.name and user.email; consider a .mailmap for history.")
76
90
  out.append(_f("warning", "Unconfigured git identity",
77
- f"\"{i['name']} <{i['email']}>\" made {i['commits']} commits ({_pct(i['commits'], total)}).",
78
- "Set user.name and user.email; consider a .mailmap for history."))
91
+ f"\"{i['name']} <{i['email']}>\" made {i['commits']} commits ({_pct(i['commits'], total)}).", advice))
79
92
  return out
80
93
 
81
94
 
@@ -130,12 +143,22 @@ def _sizer_advice(row: dict) -> str:
130
143
  return "Consider a shallow clone for CI; the history is the cost."
131
144
 
132
145
 
146
+ def _tree(report: dict) -> dict:
147
+ """The files at HEAD, from scc, or {} when the run has no size listing to judge by."""
148
+ return (report.get("size") or {}).get("files") or {}
149
+
150
+
133
151
  def sizer_concerns(report: dict) -> list:
152
+ tree = _tree(report)
134
153
  out = []
135
154
  for row in report.get("sizer") or []:
136
155
  sev = "warning" if row["concern"] >= 2 else "info"
137
156
  where = f" at {row['ref']}" if row.get("ref") else ""
138
- out.append(_f(sev, "Repo health", f"{row['name']} is {row['value']}{where}. git-sizer level of concern {row['concern']}.", _sizer_advice(row)))
157
+ advice = _sizer_advice(row)
158
+ if row.get("ref") and tree and row["name"].startswith("Blobs: ") and row["ref"] not in tree:
159
+ where += ", no longer in the tree" # deleting it did not shrink the clone
160
+ advice = "It is already gone from the tree; a history rewrite is only worth it for clone size."
161
+ out.append(_f(sev, "Repo health", f"{row['name']} is {row['value']}{where}. git-sizer level of concern {row['concern']}.", advice))
139
162
  return out
140
163
 
141
164
 
@@ -151,9 +174,12 @@ def hotspot_dominance(report: dict, ratio: float = 2.0, minimum: int = 20) -> li
151
174
 
152
175
 
153
176
  def tight_coupling(report: dict, min_degree: int = 80, min_revs: int = 5) -> list:
154
- """A file and its test are expected to change together, so pairs with a test file on either side are left out."""
177
+ """A file and its test are expected to change together, so pairs with a test file on either side are
178
+ left out; so are pairs where either file is no longer in the tree, which are history, not a dependency."""
179
+ tree = _tree(report)
155
180
  pairs = [p for p in report.get("coupling") or [] if p["degree"] >= min_degree and p["average-revs"] >= min_revs
156
- and not (filetypes.is_test_path(p["entity"]) or filetypes.is_test_path(p["coupled"]))]
181
+ and not (filetypes.is_test_path(p["entity"]) or filetypes.is_test_path(p["coupled"]))
182
+ and not (tree and (p["entity"] not in tree or p["coupled"] not in tree))]
157
183
  if not pairs:
158
184
  return []
159
185
  pairs.sort(key=lambda p: (-p["degree"], -p["average-revs"]))
@@ -169,7 +195,7 @@ def stale_files(report: dict, months: int = 12, share: float = 0.3) -> list:
169
195
  """Files still in the tree that nobody has touched. The age table covers every path in the
170
196
  history, so paths that were deleted are left out here; they are not dead code, they are gone."""
171
197
  age = report.get("age") or []
172
- tree = (report.get("size") or {}).get("files") or {}
198
+ tree = _tree(report)
173
199
  if tree:
174
200
  age = [a for a in age if a["entity"] in tree]
175
201
  if not age:
@@ -31,9 +31,14 @@ ARGV = ["betterleaks", "git", "--no-banner", "--report-format", "json", "--repor
31
31
  # the value, the text around it, and the commit message, which can quote it; Attributes repeats the message
32
32
  RAW_FIELDS = ("Secret", "Match", "Line", "Message", "Attributes")
33
33
 
34
- # A version string (5.0.0-1667386184.dfbbb54) and a token shortened with an ellipsis are the only shapes
35
- # skipped. Nothing is skipped by prefix: a public and a private key of the same service often share one.
34
+ # Shapes that cannot be a live secret: a version string (5.0.0-1667386184.dfbbb54), a token shortened
35
+ # with an ellipsis, a whole-value template marker (your-project-id, <your-token>, XXXX-XXXX, changeme),
36
+ # and a key block whose body holds no key material. Every rule is about the whole value; nothing is
37
+ # skipped by prefix, since a public and a private key of the same service often share one.
36
38
  _VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
39
+ _MARKER = re.compile(r"^(<[^<>]+>|x+|(?:x{2,}[-_ ]?)+|your[-_][\w-]+|change[-_]?me|replace[-_]?me)$", re.I)
40
+ _KEY_BLOCK = re.compile(r"-----BEGIN [A-Z ]*KEY-----(.*?)-----END [A-Z ]*KEY-----", re.S)
41
+ _KEY_MATERIAL = 64 # a real body is hundreds of base64 characters; a template has dots or a few x's
37
42
 
38
43
 
39
44
  def new_key() -> bytes:
@@ -48,8 +53,14 @@ def digest(value: str, key: bytes) -> str:
48
53
 
49
54
 
50
55
  def is_placeholder(value: str) -> bool:
51
- value = value or ""
52
- return bool(_VERSION.match(value)) or value.endswith("...") or value.endswith("…")
56
+ value = (value or "").strip()
57
+ if _VERSION.match(value) or value.endswith("...") or value.endswith("…") or _MARKER.match(value):
58
+ return True
59
+ m = _KEY_BLOCK.search(value)
60
+ if m:
61
+ body = re.sub(r"\s|\.|…|\\n", "", m.group(1)) # literal \n sequences appear in JSON samples
62
+ return len(body) < _KEY_MATERIAL
63
+ return False
53
64
 
54
65
 
55
66
  def sanitise(rows: list) -> list:
@@ -66,15 +77,16 @@ def sanitise(rows: list) -> list:
66
77
 
67
78
  def group(rows: list) -> list:
68
79
  """One entry per distinct secret value (placeholders left out): its rule, the files and commits it
69
- appears in, the number of distinct places (commit, file, line), and whether every place is a test
70
- file. Values that appear in source come first, then the most widespread."""
80
+ appears in, the number of distinct places (commit, file, line), whether every place is a test
81
+ file, and whether every place is a documentation file. Values that appear in source come first,
82
+ then the most widespread."""
71
83
  groups, order = {}, []
72
84
  for i, r in enumerate(rows):
73
85
  if r.get("placeholder"):
74
86
  continue
75
87
  key = r.get("value") or ("row", i)
76
88
  if key not in groups:
77
- groups[key] = {"value": r.get("value"), "rule": r["rule"], "files": [], "commits": [], "_places": set(), "test": True}
89
+ groups[key] = {"value": r.get("value"), "rule": r["rule"], "files": [], "commits": [], "_places": set(), "test": True, "docs": True}
78
90
  order.append(key)
79
91
  g = groups[key]
80
92
  if r["file"] not in g["files"]:
@@ -83,6 +95,7 @@ def group(rows: list) -> list:
83
95
  g["commits"].append(r["commit"])
84
96
  g["_places"].add((r["commit"], r["file"], r.get("line")))
85
97
  g["test"] = g["test"] and filetypes.is_test_path(r["file"])
98
+ g["docs"] = g["docs"] and filetypes.is_doc_path(r["file"])
86
99
  out = []
87
100
  for key in order:
88
101
  g = groups[key]
@@ -244,6 +244,9 @@ def load_report(out_dir: str, nested: bool = True) -> dict:
244
244
  "cohorts": parse_theseus(cohorts) if cohorts else {},
245
245
  "theseus_authors": surviving,
246
246
  "secrets": parse_secrets(_read(out_dir, "secrets.json")),
247
+ # the wrapper writes the file only when the scan finished, so a killed step or an old output
248
+ # directory leaves it missing, and the report must not claim a clean scan
249
+ "secrets_scanned": os.path.exists(os.path.join(out_dir, "secrets.json")),
247
250
  "activity": _read_json(out_dir, "activity.json", {}),
248
251
  "functions": parse_functions(_read(out_dir, "functions.csv")),
249
252
  "duplicates": parse_duplicates(_read(out_dir, "duplicates.txt")),
@@ -98,6 +98,17 @@ def _hide_tests(rows: list, path_of, full, noun="test file", plural=None) -> tup
98
98
  return kept, note
99
99
 
100
100
 
101
+ def _hide_gone(pairs: list, report: dict, full) -> tuple:
102
+ """Drop coupled pairs where either file is no longer in the tree, unless `full` is True: they
103
+ describe a layout that no longer exists. Returns (pairs, note) like _hide_tests."""
104
+ tree = (report.get("size") or {}).get("files") or {}
105
+ if full is True or not tree:
106
+ return pairs, None
107
+ kept = [p for p in pairs if p["entity"] in tree and p["coupled"] in tree]
108
+ hidden = len(pairs) - len(kept)
109
+ return kept, (f"{hidden} historical pair{'s' if hidden != 1 else ''} hidden; --full shows them" if hidden else None)
110
+
111
+
101
112
  def _empty_note(base, hidden_note, source_base=None) -> str:
102
113
  """The note that replaces a table with no rows left. When test rows were hidden the note has to
103
114
  carry the count, since the caption goes with the table, and what is left is the source rows."""
@@ -364,6 +375,8 @@ def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
364
375
  def coupling_section(report: dict, full: bool = True, width=None) -> dict:
365
376
  pairs = sorted((p for p in report.get("coupling") or [] if p["average-revs"] >= 5), key=lambda p: (-p["degree"], -p["average-revs"]))
366
377
  pairs, hidden_note = _hide_tests(pairs, lambda p: (p["entity"], p["coupled"]), full, noun="test pair")
378
+ pairs, gone_note = _hide_gone(pairs, report, full)
379
+ hidden_note = "; ".join(n for n in (hidden_note, gone_note) if n) or None
367
380
  limit = _limit("Change coupling", full)
368
381
  rows = [(p["entity"], p["coupled"], f"{p['degree']}%", p["average-revs"]) for p in pairs[:limit]]
369
382
  columns = [("file", PATH), ("changes with", PATH), ("degree", RIGHT), ("avg revs", RIGHT)]
@@ -500,6 +513,19 @@ def secrets_line(report: dict) -> str:
500
513
  return line
501
514
 
502
515
 
516
+ def secrets_pass(report: dict):
517
+ """The one check worth saying out loud when it passes: (title, detail) when the scan ran and found no
518
+ secret value, else None. Found values are findings already; a scan that did not run says nothing."""
519
+ rows = report.get("secrets") or []
520
+ if not report.get("secrets_scanned") or leaks.group(rows):
521
+ return None
522
+ detail = "betterleaks scanned every commit on every branch"
523
+ skipped = leaks.placeholders(rows)
524
+ if skipped:
525
+ detail += f"; {skipped} placeholder-shaped hit{'s' if skipped != 1 else ''} left out"
526
+ return "No secrets in history", detail
527
+
528
+
503
529
  # --- rich ------------------------------------------------------------------
504
530
 
505
531
  def header(report: dict, findings: list = ()) -> Panel:
@@ -519,8 +545,9 @@ def header(report: dict, findings: list = ()) -> Panel:
519
545
  return Panel(body, title=f"[bold]{s['name']}[/bold]", title_align="left", border_style="blue")
520
546
 
521
547
 
522
- def findings_panel(findings: list) -> Panel:
523
- if not findings:
548
+ def findings_panel(findings: list, report: dict = None) -> Panel:
549
+ passed = secrets_pass(report or {})
550
+ if not findings and not passed:
524
551
  return Panel(Text("Nothing flagged.", style="green"), title="Findings", title_align="left", border_style="green")
525
552
  grid = Table.grid(padding=(0, 1))
526
553
  grid.add_column(no_wrap=True)
@@ -533,7 +560,12 @@ def findings_panel(findings: list) -> Panel:
533
560
  for advice in g["advice"]:
534
561
  body.append(f"\n↳ {advice}", style="dim italic")
535
562
  grid.add_row(Text(SEVERITY_MARK[g["severity"]], style=style), body)
536
- return Panel(grid, title=f"Findings ({len(findings)})", title_align="left", border_style=SEVERITY_STYLE[findings[0]["severity"]])
563
+ if passed: # last: problems first, then the check that passed
564
+ if not findings:
565
+ grid.add_row(Text(""), Text("Nothing flagged.", style="green"))
566
+ grid.add_row(Text("✔", style="green"), Text(passed[0], style="green").append(f"\n{passed[1]}", style="dim"))
567
+ title = f"Findings ({len(findings)})" if findings else "Findings"
568
+ return Panel(grid, title=title, title_align="left", border_style=SEVERITY_STYLE[findings[0]["severity"]] if findings else "green")
537
569
 
538
570
 
539
571
  def cell_style(column: str, value: str):
@@ -619,7 +651,7 @@ def _partners(secs: list) -> dict:
619
651
 
620
652
  def report(report: dict, findings: list, console: Console, full: bool = False, risk: dict = None, base: str = None) -> None:
621
653
  console.print(header(report, findings))
622
- console.print(findings_panel(findings))
654
+ console.print(findings_panel(findings, report))
623
655
  secs = sections(report, full=full, width=console.width)
624
656
  by_id = {s["id"]: s for s in secs}
625
657
  partners = _partners(secs) if console.width >= SIDE_BY_SIDE_MIN_WIDTH else {}
@@ -650,14 +682,15 @@ def _md_cell(cell: str) -> str:
650
682
  return cell.replace("|", "\\|").replace("\n", " ")
651
683
 
652
684
 
653
- def _md_findings(findings: list) -> list:
654
- if not findings:
655
- return ["Nothing flagged."]
656
- out = []
685
+ def _md_findings(findings: list, report: dict = None) -> list:
686
+ out = [] if findings else ["Nothing flagged."]
657
687
  for g in textfmt.group_findings(findings):
658
688
  line = f"- **{g['severity']}** {g['title']} — " + "; ".join(g["items"])
659
689
  line += "".join(f" _{advice}_" for advice in g["advice"])
660
690
  out.append(line)
691
+ passed = secrets_pass(report or {})
692
+ if passed:
693
+ out.append(f"- **ok** {passed[0]} — {passed[1]}")
661
694
  return out
662
695
 
663
696
 
@@ -668,7 +701,7 @@ def markdown(report: dict, findings: list, full: bool = False, risk: dict = None
668
701
  f"{s['lines']:,} lines in {s['files']} files · {', '.join(s['languages']) or 'unknown'}" + (" " if s["pulse"] else ""),
669
702
  *([" · ".join(s["pulse"])] if s["pulse"] else []), "",
670
703
  "## Findings", ""]
671
- out += _md_findings(findings)
704
+ out += _md_findings(findings, report)
672
705
  secs = sections(report, full=True if full else "markdown")
673
706
  if risk is not None:
674
707
  after = next((i for i, sec in enumerate(secs) if sec["id"] == "watch"), len(secs) - 1)
@@ -728,7 +761,7 @@ def portfolio_markdown(owner: str, reports: list) -> str:
728
761
  out.append(f"_{sec['note']}_")
729
762
  for name, rep, found in reports:
730
763
  out += ["", f"## {name}", ""]
731
- out += _md_findings(found)
764
+ out += _md_findings(found, rep)
732
765
  return "\n".join(out) + "\n"
733
766
 
734
767