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.
- gitmole-0.6.0/PKG-INFO +159 -0
- gitmole-0.6.0/README.md +139 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/__init__.py +1 -1
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/filetypes.py +9 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/findings.py +41 -15
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/leaks.py +20 -7
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/load.py +3 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/render.py +43 -10
- gitmole-0.6.0/gitmole.egg-info/PKG-INFO +159 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_filetypes.py +6 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_findings.py +42 -1
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_leaks.py +25 -1
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_load.py +13 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_render.py +42 -0
- gitmole-0.4.0/PKG-INFO +0 -470
- gitmole-0.4.0/README.md +0 -450
- gitmole-0.4.0/gitmole.egg-info/PKG-INFO +0 -470
- {gitmole-0.4.0 → gitmole-0.6.0}/LICENSE +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/__main__.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/backtest.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/banner.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/blame.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/cli.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/functions.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/hotspots.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/identity.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/knowledge.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/loss.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/maat.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/run.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/textfmt.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/trend.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole/watch.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole.egg-info/SOURCES.txt +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/pyproject.toml +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/setup.cfg +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_backtest.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_banner.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_blame.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_cli.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_functions.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_golden.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_hotspots.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_identity.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_knowledge.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_loss.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_maat.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_packaging.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_run.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_textfmt.py +0 -0
- {gitmole-0.4.0 → gitmole-0.6.0}/tests/test_trend.py +0 -0
- {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).
|
gitmole-0.6.0/README.md
ADDED
|
@@ -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).
|
|
@@ -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
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
54
|
-
out.append(_f("warning", f"{len(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
35
|
-
#
|
|
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
|
-
|
|
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),
|
|
70
|
-
file. Values that appear in source come first,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|