gitmole 0.3.0__tar.gz → 0.5.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.5.0/PKG-INFO +159 -0
- gitmole-0.5.0/README.md +139 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/__init__.py +1 -1
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/cli.py +1 -1
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/findings.py +1 -1
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/leaks.py +9 -8
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/load.py +4 -1
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/render.py +30 -10
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/run.py +2 -2
- gitmole-0.5.0/gitmole.egg-info/PKG-INFO +159 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_findings.py +2 -2
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_leaks.py +12 -10
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_load.py +13 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_render.py +30 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_run.py +6 -6
- gitmole-0.3.0/PKG-INFO +0 -469
- gitmole-0.3.0/README.md +0 -449
- gitmole-0.3.0/gitmole.egg-info/PKG-INFO +0 -469
- {gitmole-0.3.0 → gitmole-0.5.0}/LICENSE +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/__main__.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/backtest.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/banner.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/blame.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/filetypes.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/functions.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/hotspots.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/identity.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/knowledge.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/loss.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/maat.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/textfmt.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/trend.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole/watch.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole.egg-info/SOURCES.txt +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/pyproject.toml +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/setup.cfg +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_backtest.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_banner.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_blame.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_cli.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_filetypes.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_functions.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_golden.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_hotspots.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_identity.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_knowledge.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_loss.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_maat.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_packaging.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_textfmt.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_trend.py +0 -0
- {gitmole-0.3.0 → gitmole-0.5.0}/tests/test_watch.py +0 -0
gitmole-0.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitmole
|
|
3
|
+
Version: 0.5.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.5.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).
|
|
@@ -95,7 +95,7 @@ def main(argv=None, console: Console = None, tool_check=run.missing_tools, plann
|
|
|
95
95
|
missing = tool_check(plots=args.plots)
|
|
96
96
|
if missing:
|
|
97
97
|
err.print("[red]missing tools:[/red] " + ", ".join(missing))
|
|
98
|
-
err.print("brew install scc git-sizer
|
|
98
|
+
err.print("brew install scc git-sizer betterleaks; see README.md for other ways")
|
|
99
99
|
return 2
|
|
100
100
|
args.lizard = lizard_check() # decided once, for every repository this run analyses
|
|
101
101
|
|
|
@@ -45,7 +45,7 @@ def secrets_found(report: dict) -> list:
|
|
|
45
45
|
groups = leaks.group(report.get("secrets") or [])
|
|
46
46
|
source = [g for g in groups if not g["test"]]
|
|
47
47
|
tests = [g for g in groups if g["test"]]
|
|
48
|
-
ignore = "Add the fingerprint of any false positive from secrets.json to .
|
|
48
|
+
ignore = "Add the fingerprint of any false positive from secrets.json to .betterleaksignore in the repository."
|
|
49
49
|
out = []
|
|
50
50
|
if source:
|
|
51
51
|
out.append(_f("critical", f"{len(source)} secret(s) in history", _secret_statement(source),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""betterleaks, with the secret values kept out of the output directory.
|
|
3
3
|
|
|
4
|
-
gitmole runs this as the
|
|
5
|
-
|
|
4
|
+
gitmole runs this as the betterleaks step: `python3 leaks.py OUT_JSON`, from inside the repository.
|
|
5
|
+
betterleaks writes its JSON report to our stdout, so the raw report is never a file; each value is
|
|
6
6
|
replaced by a short keyed hash (enough to tell one value repeated in many places from many values)
|
|
7
7
|
and a flag for shapes that cannot be a live secret, and only that is written. The key is random,
|
|
8
8
|
made for one report and never stored, so a hash in secrets.json cannot be checked against a list of
|
|
@@ -27,8 +27,9 @@ try:
|
|
|
27
27
|
except ImportError: # run as a script: the package directory is sys.path[0]
|
|
28
28
|
import filetypes
|
|
29
29
|
|
|
30
|
-
ARGV = ["
|
|
31
|
-
|
|
30
|
+
ARGV = ["betterleaks", "git", "--no-banner", "--report-format", "json", "--report-path", "-", "--exit-code", "0"]
|
|
31
|
+
# the value, the text around it, and the commit message, which can quote it; Attributes repeats the message
|
|
32
|
+
RAW_FIELDS = ("Secret", "Match", "Line", "Message", "Attributes")
|
|
32
33
|
|
|
33
34
|
# A version string (5.0.0-1667386184.dfbbb54) and a token shortened with an ellipsis are the only shapes
|
|
34
35
|
# skipped. Nothing is skipped by prefix: a public and a private key of the same service often share one.
|
|
@@ -102,13 +103,13 @@ def main(argv=None) -> int:
|
|
|
102
103
|
print("usage: leaks.py OUT_JSON", file=sys.stderr)
|
|
103
104
|
return 2
|
|
104
105
|
target = args[0]
|
|
105
|
-
# stderr is inherited, so
|
|
106
|
+
# stderr is inherited, so betterleaks' own log lands in run.log as before
|
|
106
107
|
proc = subprocess.run(ARGV, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
|
|
107
108
|
if proc.returncode != 0:
|
|
108
|
-
print(f"leaks.py:
|
|
109
|
+
print(f"leaks.py: betterleaks exited {proc.returncode}; no report written", file=sys.stderr)
|
|
109
110
|
return proc.returncode
|
|
110
111
|
text = proc.stdout.decode("utf-8", "surrogateescape").strip()
|
|
111
|
-
rows = sanitise(json.loads(text) if text else [])
|
|
112
|
+
rows = sanitise((json.loads(text) if text else None) or []) # a clean repository is reported as null
|
|
112
113
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(target)), prefix=".secrets-", suffix=".json")
|
|
113
114
|
try:
|
|
114
115
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
@@ -180,7 +180,7 @@ def parse_duplicates(text: str) -> dict:
|
|
|
180
180
|
|
|
181
181
|
|
|
182
182
|
def parse_secrets(text: str) -> list:
|
|
183
|
-
"""
|
|
183
|
+
"""betterleaks rows as rule, file, short commit, line, fingerprint, the hashed value and the placeholder
|
|
184
184
|
flag. A report written before values were hashed still has them: hash them here, keep nothing raw."""
|
|
185
185
|
rows = json.loads(text) if text.strip() else []
|
|
186
186
|
key = leaks.new_key() # for an older report with raw values: one key per read, as the wrapper does per run
|
|
@@ -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")),
|
|
@@ -500,6 +500,19 @@ def secrets_line(report: dict) -> str:
|
|
|
500
500
|
return line
|
|
501
501
|
|
|
502
502
|
|
|
503
|
+
def secrets_pass(report: dict):
|
|
504
|
+
"""The one check worth saying out loud when it passes: (title, detail) when the scan ran and found no
|
|
505
|
+
secret value, else None. Found values are findings already; a scan that did not run says nothing."""
|
|
506
|
+
rows = report.get("secrets") or []
|
|
507
|
+
if not report.get("secrets_scanned") or leaks.group(rows):
|
|
508
|
+
return None
|
|
509
|
+
detail = "betterleaks scanned every commit on every branch"
|
|
510
|
+
skipped = leaks.placeholders(rows)
|
|
511
|
+
if skipped:
|
|
512
|
+
detail += f"; {skipped} placeholder-shaped hit{'s' if skipped != 1 else ''} left out"
|
|
513
|
+
return "No secrets in history", detail
|
|
514
|
+
|
|
515
|
+
|
|
503
516
|
# --- rich ------------------------------------------------------------------
|
|
504
517
|
|
|
505
518
|
def header(report: dict, findings: list = ()) -> Panel:
|
|
@@ -519,8 +532,9 @@ def header(report: dict, findings: list = ()) -> Panel:
|
|
|
519
532
|
return Panel(body, title=f"[bold]{s['name']}[/bold]", title_align="left", border_style="blue")
|
|
520
533
|
|
|
521
534
|
|
|
522
|
-
def findings_panel(findings: list) -> Panel:
|
|
523
|
-
|
|
535
|
+
def findings_panel(findings: list, report: dict = None) -> Panel:
|
|
536
|
+
passed = secrets_pass(report or {})
|
|
537
|
+
if not findings and not passed:
|
|
524
538
|
return Panel(Text("Nothing flagged.", style="green"), title="Findings", title_align="left", border_style="green")
|
|
525
539
|
grid = Table.grid(padding=(0, 1))
|
|
526
540
|
grid.add_column(no_wrap=True)
|
|
@@ -533,7 +547,12 @@ def findings_panel(findings: list) -> Panel:
|
|
|
533
547
|
for advice in g["advice"]:
|
|
534
548
|
body.append(f"\n↳ {advice}", style="dim italic")
|
|
535
549
|
grid.add_row(Text(SEVERITY_MARK[g["severity"]], style=style), body)
|
|
536
|
-
|
|
550
|
+
if passed: # last: problems first, then the check that passed
|
|
551
|
+
if not findings:
|
|
552
|
+
grid.add_row(Text(""), Text("Nothing flagged.", style="green"))
|
|
553
|
+
grid.add_row(Text("✔", style="green"), Text(passed[0], style="green").append(f"\n{passed[1]}", style="dim"))
|
|
554
|
+
title = f"Findings ({len(findings)})" if findings else "Findings"
|
|
555
|
+
return Panel(grid, title=title, title_align="left", border_style=SEVERITY_STYLE[findings[0]["severity"]] if findings else "green")
|
|
537
556
|
|
|
538
557
|
|
|
539
558
|
def cell_style(column: str, value: str):
|
|
@@ -619,7 +638,7 @@ def _partners(secs: list) -> dict:
|
|
|
619
638
|
|
|
620
639
|
def report(report: dict, findings: list, console: Console, full: bool = False, risk: dict = None, base: str = None) -> None:
|
|
621
640
|
console.print(header(report, findings))
|
|
622
|
-
console.print(findings_panel(findings))
|
|
641
|
+
console.print(findings_panel(findings, report))
|
|
623
642
|
secs = sections(report, full=full, width=console.width)
|
|
624
643
|
by_id = {s["id"]: s for s in secs}
|
|
625
644
|
partners = _partners(secs) if console.width >= SIDE_BY_SIDE_MIN_WIDTH else {}
|
|
@@ -650,14 +669,15 @@ def _md_cell(cell: str) -> str:
|
|
|
650
669
|
return cell.replace("|", "\\|").replace("\n", " ")
|
|
651
670
|
|
|
652
671
|
|
|
653
|
-
def _md_findings(findings: list) -> list:
|
|
654
|
-
if
|
|
655
|
-
return ["Nothing flagged."]
|
|
656
|
-
out = []
|
|
672
|
+
def _md_findings(findings: list, report: dict = None) -> list:
|
|
673
|
+
out = [] if findings else ["Nothing flagged."]
|
|
657
674
|
for g in textfmt.group_findings(findings):
|
|
658
675
|
line = f"- **{g['severity']}** {g['title']} — " + "; ".join(g["items"])
|
|
659
676
|
line += "".join(f" _{advice}_" for advice in g["advice"])
|
|
660
677
|
out.append(line)
|
|
678
|
+
passed = secrets_pass(report or {})
|
|
679
|
+
if passed:
|
|
680
|
+
out.append(f"- **ok** {passed[0]} — {passed[1]}")
|
|
661
681
|
return out
|
|
662
682
|
|
|
663
683
|
|
|
@@ -668,7 +688,7 @@ def markdown(report: dict, findings: list, full: bool = False, risk: dict = None
|
|
|
668
688
|
f"{s['lines']:,} lines in {s['files']} files · {', '.join(s['languages']) or 'unknown'}" + (" " if s["pulse"] else ""),
|
|
669
689
|
*([" · ".join(s["pulse"])] if s["pulse"] else []), "",
|
|
670
690
|
"## Findings", ""]
|
|
671
|
-
out += _md_findings(findings)
|
|
691
|
+
out += _md_findings(findings, report)
|
|
672
692
|
secs = sections(report, full=True if full else "markdown")
|
|
673
693
|
if risk is not None:
|
|
674
694
|
after = next((i for i, sec in enumerate(secs) if sec["id"] == "watch"), len(secs) - 1)
|
|
@@ -728,7 +748,7 @@ def portfolio_markdown(owner: str, reports: list) -> str:
|
|
|
728
748
|
out.append(f"_{sec['note']}_")
|
|
729
749
|
for name, rep, found in reports:
|
|
730
750
|
out += ["", f"## {name}", ""]
|
|
731
|
-
out += _md_findings(found)
|
|
751
|
+
out += _md_findings(found, rep)
|
|
732
752
|
return "\n".join(out) + "\n"
|
|
733
753
|
|
|
734
754
|
|
|
@@ -109,7 +109,7 @@ def env_path() -> str:
|
|
|
109
109
|
return os.pathsep.join(parts + [os.environ.get("PATH", "")])
|
|
110
110
|
|
|
111
111
|
|
|
112
|
-
REQUIRED_TOOLS = ["scc", "git-sizer", "
|
|
112
|
+
REQUIRED_TOOLS = ["scc", "git-sizer", "betterleaks"]
|
|
113
113
|
PLOT_TOOLS = ["git-of-theseus-analyze"]
|
|
114
114
|
|
|
115
115
|
|
|
@@ -168,7 +168,7 @@ def plan(repo_dir: str, out_dir: str, branch: str = "HEAD", age: bool = True, pl
|
|
|
168
168
|
steps = [
|
|
169
169
|
{"name": "scc", "argv": ["scc", "--by-file", "--format", "json"], "stdout": o("size.json"), "deps": []},
|
|
170
170
|
{"name": "git-sizer", "argv": ["git-sizer", "--verbose"], "stdout": o("repo-health.txt"), "deps": []},
|
|
171
|
-
{"name": "
|
|
171
|
+
{"name": "betterleaks", "argv": [sys.executable, LEAKS_SCRIPT, o("secrets.json")], "stdout": None, "deps": []}, # hashes the values before anything is written
|
|
172
172
|
{"name": "git-log", "argv": [*filetypes.GIT, "log", "--all", "--use-mailmap", "--numstat", "--date=iso-strict", "--pretty=format:--%h--%ad--%aN--%s", "--no-renames"], "stdout": log, "deps": []},
|
|
173
173
|
{"name": "change analysis", "argv": [sys.executable, MAAT_SCRIPT, log, out_dir, *type_args, *(["--now", now] if now else []), *(["--since", since] if since else []), "--aliases", o("meta.json")], "stdout": None, "deps": ["git-log"]},
|
|
174
174
|
]
|