gitmole 0.6.6__tar.gz → 0.6.8__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.6 → gitmole-0.6.8}/PKG-INFO +1 -1
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/__init__.py +1 -1
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/filetypes.py +3 -2
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/findings.py +44 -9
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/identity.py +20 -6
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/leaks.py +6 -1
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/maat.py +17 -2
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/render.py +2 -1
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/run.py +1 -1
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/watch.py +2 -2
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole.egg-info/PKG-INFO +1 -1
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_filetypes.py +3 -2
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_findings.py +48 -3
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_identity.py +10 -1
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_leaks.py +8 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_maat.py +12 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_render.py +11 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_run.py +2 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_watch.py +8 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/LICENSE +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/README.md +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/__main__.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/backtest.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/banner.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/blame.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/clean.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/cli.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/coupling.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/functions.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/hotspots.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/knowledge.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/load.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/loss.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/textfmt.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole/trend.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole.egg-info/SOURCES.txt +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/pyproject.toml +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/setup.cfg +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_backtest.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_banner.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_blame.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_clean.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_cli.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_coupling.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_functions.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_golden.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_hotspots.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_knowledge.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_load.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_loss.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_packaging.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_textfmt.py +0 -0
- {gitmole-0.6.6 → gitmole-0.6.8}/tests/test_trend.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.8
|
|
4
4
|
Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/antvinni/gitmole
|
|
@@ -76,12 +76,13 @@ def is_sample_path(path: str) -> bool:
|
|
|
76
76
|
return bool(_SAMPLE_PATH.search(path))
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
_VENDOR_PATH = re.compile(r"(^|/)(_?vendor|node_modules|third_?party|external)(/|$)", re.I)
|
|
79
|
+
_VENDOR_PATH = re.compile(r"(^|/)(_?vendor|vendored|node_modules|third_?party|external)(/|$)|^[^/]+/packages/", re.I)
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
def is_vendor_path(path: str) -> bool:
|
|
83
83
|
"""Vendored and third-party trees: somebody else's code, so its complexity and its single
|
|
84
|
-
importer are not this repository's risk.
|
|
84
|
+
importer are not this repository's risk. A `packages/` inside a package (requests/packages/,
|
|
85
|
+
the Python vendoring convention) counts; a monorepo's own `packages/` at the root does not."""
|
|
85
86
|
return bool(_VENDOR_PATH.search(path))
|
|
86
87
|
|
|
87
88
|
|
|
@@ -47,7 +47,8 @@ def secrets_found(report: dict) -> list:
|
|
|
47
47
|
groups = leaks.group(report.get("secrets") or [])
|
|
48
48
|
|
|
49
49
|
def in_source(g):
|
|
50
|
-
return any(not (filetypes.is_test_path(f) or filetypes.is_doc_path(f) or filetypes.is_sample_path(f)
|
|
50
|
+
return any(not (filetypes.is_test_path(f) or filetypes.is_doc_path(f) or filetypes.is_sample_path(f) or filetypes.is_vendor_path(f))
|
|
51
|
+
for f in g["files"])
|
|
51
52
|
source = [g for g in groups if in_source(g)]
|
|
52
53
|
aside = [g for g in groups if not in_source(g)]
|
|
53
54
|
ignore = "Add the fingerprint of any false positive from secrets.json to .betterleaksignore in the repository."
|
|
@@ -56,7 +57,7 @@ def secrets_found(report: dict) -> list:
|
|
|
56
57
|
out.append(_f("critical", f"{len(source)} secret(s) in history", _secret_statement(source),
|
|
57
58
|
f"Rotate them; deleting the file does not remove them from git. {ignore}"))
|
|
58
59
|
if aside:
|
|
59
|
-
out.append(_f("warning", f"{len(aside)} secret(s) only in test, example or documentation files", _secret_statement(aside),
|
|
60
|
+
out.append(_f("warning", f"{len(aside)} secret(s) only in test, example, vendored or documentation files", _secret_statement(aside),
|
|
60
61
|
f"Confirm they are fixtures or templates, not live keys. {ignore}"))
|
|
61
62
|
return out
|
|
62
63
|
|
|
@@ -173,8 +174,10 @@ def sizer_concerns(report: dict) -> list:
|
|
|
173
174
|
|
|
174
175
|
|
|
175
176
|
def hotspot_dominance(report: dict, ratio: float = 2.0, minimum: int = 20) -> list:
|
|
176
|
-
"""One source file takes most of the churn. Test files are left out: they change with everything.
|
|
177
|
-
|
|
177
|
+
"""One source file takes most of the churn. Test files are left out: they change with everything.
|
|
178
|
+
So is release plumbing: a version file or a manifest changes on every release by design."""
|
|
179
|
+
revs = sorted((r for r in report.get("revisions") or [] if not (filetypes.is_test_path(r["entity"]) or filetypes.is_release_path(r["entity"]))),
|
|
180
|
+
key=lambda r: -r["n-revs"])
|
|
178
181
|
if len(revs) < 2 or revs[0]["n-revs"] < minimum or revs[0]["n-revs"] < ratio * revs[1]["n-revs"]:
|
|
179
182
|
return []
|
|
180
183
|
top, nxt = revs[0], revs[1]
|
|
@@ -213,9 +216,39 @@ def tight_coupling(report: dict, min_degree: int = 80, min_revs: int = 5) -> lis
|
|
|
213
216
|
f"Review {first['entity']} and {first['coupled']} first: a shared layout or a hidden dependency links them.")]
|
|
214
217
|
|
|
215
218
|
|
|
219
|
+
def _months_apart(earlier: str, later: str) -> int:
|
|
220
|
+
"""Whole months from one ISO date to another."""
|
|
221
|
+
y1, m1, d1 = (int(x) for x in earlier[:10].split("-"))
|
|
222
|
+
y2, m2, d2 = (int(x) for x in later[:10].split("-"))
|
|
223
|
+
return (y2 - y1) * 12 + (m2 - m1) - (1 if d2 < d1 else 0)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _dormant_months(report: dict) -> int:
|
|
227
|
+
"""Months since the last commit, against the run's reference date (GITMOLE_NOW or today)."""
|
|
228
|
+
import datetime as _dt
|
|
229
|
+
last = report["meta"].get("last_date")
|
|
230
|
+
if not last:
|
|
231
|
+
return 0
|
|
232
|
+
now = report["meta"].get("now") or _dt.date.today().isoformat()
|
|
233
|
+
return max(0, _months_apart(last, now))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def dormant(report: dict, months: int = 12) -> list:
|
|
237
|
+
"""No commits for a year or more: everything else in the report describes a repository that has
|
|
238
|
+
stopped, which is the first thing to know about it."""
|
|
239
|
+
idle = _dormant_months(report)
|
|
240
|
+
if idle < months:
|
|
241
|
+
return []
|
|
242
|
+
return [_f("warning", "Dormant repository", f"No commits since {report['meta']['last_date']}, {idle} months ago.",
|
|
243
|
+
"The rest of the report describes a repository that has stopped; look for a successor or an archive notice before depending on it.")]
|
|
244
|
+
|
|
245
|
+
|
|
216
246
|
def stale_files(report: dict, months: int = 12, share: float = 0.3) -> list:
|
|
217
247
|
"""Files still in the tree that nobody has touched. The age table covers every path in the
|
|
218
|
-
history, so paths that were deleted are left out here; they are not dead code, they are gone.
|
|
248
|
+
history, so paths that were deleted are left out here; they are not dead code, they are gone.
|
|
249
|
+
In a dormant repository every file is untouched because nothing is; the dormancy finding says so."""
|
|
250
|
+
if _dormant_months(report) >= months:
|
|
251
|
+
return []
|
|
219
252
|
age = report.get("age") or []
|
|
220
253
|
tree = _tree(report)
|
|
221
254
|
if tree:
|
|
@@ -231,8 +264,10 @@ def stale_files(report: dict, months: int = 12, share: float = 0.3) -> list:
|
|
|
231
264
|
|
|
232
265
|
|
|
233
266
|
def bug_magnets(report: dict, min_recent: int = 3, warn_at: int = 5) -> list:
|
|
234
|
-
"""Source files with a run of recent fix commits. Test files are left out: they change with every fix.
|
|
235
|
-
|
|
267
|
+
"""Source files with a run of recent fix commits. Test files are left out: they change with every fix.
|
|
268
|
+
So is release plumbing: a manifest touched by every fix release is not where the bug was."""
|
|
269
|
+
hot = [f for f in report.get("fixes") or [] if f["recent-fixes"] >= min_recent
|
|
270
|
+
and not (filetypes.is_test_path(f["entity"]) or filetypes.is_release_path(f["entity"]))]
|
|
236
271
|
if not hot:
|
|
237
272
|
return []
|
|
238
273
|
hot.sort(key=lambda f: (-f["recent-fixes"], -f["n-fixes"], f["entity"]))
|
|
@@ -462,8 +497,8 @@ def duplication(report: dict, min_lines: int = 30) -> list:
|
|
|
462
497
|
f"Extract the {first['lines']}-line block {where} first.")]
|
|
463
498
|
|
|
464
499
|
|
|
465
|
-
RULES = [secrets_found, placeholder_identity, bus_factor, sizer_concerns, hotspot_dominance, bug_magnets, reverts, brain_methods,
|
|
466
|
-
tight_coupling, duplication, stale_files, knowledge_islands, knowledge_loss]
|
|
500
|
+
RULES = [dormant, secrets_found, placeholder_identity, bus_factor, sizer_concerns, hotspot_dominance, bug_magnets, reverts, brain_methods,
|
|
501
|
+
complexity_growth, tight_coupling, duplication, stale_files, knowledge_islands, knowledge_loss]
|
|
467
502
|
|
|
468
503
|
|
|
469
504
|
def evaluate(report: dict) -> list:
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import re
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
_BOT_WORDS = ("dependabot", "renovate", "github-actions", "github actions")
|
|
7
|
+
_BOT_WORDS = ("dependabot", "renovate", "github-actions", "github actions", "copilot", "cursor agent", "cursor-agent", "cursoragent")
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def is_bot(name: str, email: str = "") -> bool:
|
|
@@ -33,13 +33,27 @@ def _plain(name: str) -> str:
|
|
|
33
33
|
return " ".join(name.lower().split())
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def _distinctive(token: str) -> bool:
|
|
37
|
+
"""A word that names one person on its own: five letters or more and not a common first name."""
|
|
38
|
+
return len(token) >= 5 and token not in _COMMON_FIRST_NAMES
|
|
39
|
+
|
|
40
|
+
|
|
36
41
|
def same_person(a: dict, b: dict) -> bool:
|
|
37
|
-
"""Same email
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
"""Same email; two shared name tokens; the same name spelled identically (a handle such as KaKa
|
|
43
|
+
under three emails), unless that name is a bare common first name; or a one-word handle that is
|
|
44
|
+
one distinctive word of the other's fuller name (junegunn and Junegunn Choi)."""
|
|
45
|
+
if a["email"].lower() == b["email"].lower():
|
|
46
|
+
return True
|
|
47
|
+
ta, tb = _tokens(a["name"]), _tokens(b["name"])
|
|
48
|
+
if len(ta & tb) >= 2:
|
|
49
|
+
return True
|
|
50
|
+
na, nb = _plain(a["name"]), _plain(b["name"])
|
|
51
|
+
if na and na == nb and na not in _COMMON_FIRST_NAMES:
|
|
40
52
|
return True
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
for handle, full in ((na, tb), (nb, ta)):
|
|
54
|
+
if " " not in handle and handle in full and len(full) >= 2 and _distinctive(handle):
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
43
57
|
|
|
44
58
|
|
|
45
59
|
def merge(identities: list) -> list:
|
|
@@ -40,6 +40,10 @@ _VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
|
|
40
40
|
_MARKER = re.compile(r"^(<[^<>]+>|x+|(?:x{2,}[-_ ]?)+|your[-_][\w-]+|change[-_]?me|replace[-_]?me)$", re.I)
|
|
41
41
|
_EXAMPLE_WORDS = {"password", "passwd", "pass", "secret", "hello", "hey", "test", "example", "sample", "dummy", "foo", "bar",
|
|
42
42
|
"baz", "admin", "root", "user", "123456", "12345678", "123456789", "abc123", "qwerty", "letmein", "welcome"}
|
|
43
|
+
# A whole value that refers to an environment variable or a template field is where the secret will be
|
|
44
|
+
# read from, not the secret: `@env:AC_PASSWORD`, `${DB_PASSWORD}`, `{{.Env.X}}`, `process.env.X`, `<%= ENV['X'] %>`.
|
|
45
|
+
_ENV_REF = re.compile(r"^(@env:\w+|\$\{[^{}]+\}|\$[A-Za-z_]\w*|\$\([^()]+\)|%[A-Za-z_]\w*%|\{\{.*\}\}|<%=?.*%>"
|
|
46
|
+
r"|process\.env\.\w+|os\.environ(\[[^\]]+\]|\.get\([^()]*\))|ENV\[[^\]]+\])$", re.S)
|
|
43
47
|
_KEY_BLOCK = re.compile(r"-----BEGIN [A-Z ]*KEY-----(.*?)-----END [A-Z ]*KEY-----", re.S)
|
|
44
48
|
_KEY_MATERIAL = 64 # a real body is hundreds of base64 characters; a template has dots or a few x's
|
|
45
49
|
|
|
@@ -57,7 +61,8 @@ def digest(value: str, key: bytes) -> str:
|
|
|
57
61
|
|
|
58
62
|
def is_placeholder(value: str) -> bool:
|
|
59
63
|
value = (value or "").strip()
|
|
60
|
-
if _VERSION.match(value) or value.endswith("...") or value.endswith("…") or _MARKER.match(value) or value.lower() in _EXAMPLE_WORDS
|
|
64
|
+
if (_VERSION.match(value) or value.endswith("...") or value.endswith("…") or _MARKER.match(value) or value.lower() in _EXAMPLE_WORDS
|
|
65
|
+
or _ENV_REF.match(value)):
|
|
61
66
|
return True
|
|
62
67
|
m = _KEY_BLOCK.search(value)
|
|
63
68
|
if m:
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
Standalone on purpose: gitmole runs it as a pipeline step with
|
|
5
5
|
`python3 maat.py LOG OUT_DIR [--aliases META_JSON]` and it must not need the
|
|
6
6
|
package on sys.path. Input is `git log --all --numstat --date=short
|
|
7
|
-
--pretty=format:--%h--%ad--%aN
|
|
7
|
+
--pretty=format:--%h--%ad--%aN -M`: renames are followed, so a moved file
|
|
8
|
+
is one entity under its new path and a pure move adds and deletes nothing.
|
|
9
|
+
Whoever moved a directory to src/ did not write it.
|
|
8
10
|
"""
|
|
9
11
|
from __future__ import annotations
|
|
10
12
|
|
|
@@ -40,13 +42,26 @@ def parse_log(text: str, aliases: dict = None, types=None) -> list:
|
|
|
40
42
|
commits.append(current)
|
|
41
43
|
elif line.strip() and current is not None:
|
|
42
44
|
added, deleted, path = line.split("\t", 2)
|
|
43
|
-
path = filetypes.unquote(path)
|
|
45
|
+
path = _renamed_to(filetypes.unquote(path))
|
|
44
46
|
if not filetypes.matches(path, types):
|
|
45
47
|
continue
|
|
46
48
|
current["files"].append((path, int(added) if added.isdigit() else 0, int(deleted) if deleted.isdigit() else 0))
|
|
47
49
|
return commits
|
|
48
50
|
|
|
49
51
|
|
|
52
|
+
_BRACED_RENAME = re.compile(r"\{([^{}]*) => ([^{}]*)\}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _renamed_to(path: str) -> str:
|
|
56
|
+
"""The new path of a rename as `git log -M --numstat` spells it: `{old => new}/rest`,
|
|
57
|
+
`dir/{a => b}` or `old => new` for a whole path. A path without ' => ' is itself."""
|
|
58
|
+
if " => " not in path:
|
|
59
|
+
return path
|
|
60
|
+
if "{" in path:
|
|
61
|
+
return _BRACED_RENAME.sub(lambda m: m.group(2), path).replace("//", "/")
|
|
62
|
+
return path.split(" => ", 1)[1]
|
|
63
|
+
|
|
64
|
+
|
|
50
65
|
_FIX_CONVENTIONAL = re.compile(r"^(fix|hotfix|bugfix)(\([^)]*\))?!?:", re.I)
|
|
51
66
|
_FIX_WORDS = re.compile(r"\b(fix|fixes|fixed|fixing|bugfix|hotfix|bug|bugs|regression|crash|crashes)\b", re.I)
|
|
52
67
|
|
|
@@ -395,7 +395,8 @@ def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
395
395
|
scored, hidden_note = _hide_tests(scored, lambda h: h["entity"], full)
|
|
396
396
|
scored, deleted_note = _hide_deleted(scored, report, full)
|
|
397
397
|
scored, generated_note = _hide_generated(scored, lambda h: h["entity"], report, full)
|
|
398
|
-
|
|
398
|
+
scored, release_note = _hide_rows(scored, lambda h: h["entity"], full, filetypes.is_release_path, "release file")
|
|
399
|
+
hidden_note = _join_hidden(hidden_note, deleted_note, generated_note, release_note)
|
|
399
400
|
title = "Hotspots (score = revisions × lines of code)" if full is True else "Hotspots"
|
|
400
401
|
limit = _limit("Hotspots", full)
|
|
401
402
|
series = (report.get("trend") or {}).get("files") or {}
|
|
@@ -172,7 +172,7 @@ def plan(repo_dir: str, out_dir: str, branch: str = "HEAD", age: bool = True, pl
|
|
|
172
172
|
{"name": "scc", "argv": ["scc", "--by-file", "--format", "json"], "stdout": o("size.json"), "deps": []},
|
|
173
173
|
{"name": "git-sizer", "argv": ["git-sizer", "--verbose"], "stdout": o("repo-health.txt"), "deps": []},
|
|
174
174
|
{"name": "betterleaks", "argv": [sys.executable, LEAKS_SCRIPT, o("secrets.json")], "stdout": None, "deps": []}, # hashes the values before anything is written
|
|
175
|
-
{"name": "git-log", "argv": [*filetypes.GIT, "log", "--all", "--use-mailmap", "--numstat", "--date=iso-strict", "--pretty=format:--%h--%ad--%aN--%s", "
|
|
175
|
+
{"name": "git-log", "argv": [*filetypes.GIT, "log", "--all", "--use-mailmap", "--numstat", "--date=iso-strict", "--pretty=format:--%h--%ad--%aN--%s", "-M"], "stdout": log, "deps": []}, # -M: a move is not an edit
|
|
176
176
|
{"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"]},
|
|
177
177
|
]
|
|
178
178
|
if lizard:
|
|
@@ -66,8 +66,8 @@ def risks(report: dict, min_revs: int = 2) -> list:
|
|
|
66
66
|
|
|
67
67
|
rows = []
|
|
68
68
|
for h in hotspots.ranked(report):
|
|
69
|
-
if h["code"] is None or h["revs"] < min_revs or filetypes.is_test_path(h["entity"]):
|
|
70
|
-
continue
|
|
69
|
+
if h["code"] is None or h["revs"] < min_revs or filetypes.is_test_path(h["entity"]) or filetypes.is_release_path(h["entity"]):
|
|
70
|
+
continue # a version file or a manifest changes on every release, not where the next bug lands
|
|
71
71
|
fx = fixes.get(h["entity"], {})
|
|
72
72
|
own = owners.get(h["entity"]) or Counter()
|
|
73
73
|
owner, owner_lines = (own.most_common(1)[0] if own else (None, 0))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.8
|
|
4
4
|
Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/antvinni/gitmole
|
|
@@ -133,9 +133,10 @@ class TestPaths(unittest.TestCase):
|
|
|
133
133
|
|
|
134
134
|
def test_vendored_trees(self):
|
|
135
135
|
for path in ("vendor/github.com/x/y.go", "web/node_modules/a/index.js", "third_party/z/a.c", "thirdparty/a.c", "_vendor/a.py",
|
|
136
|
-
"external/lib/a.cpp"):
|
|
136
|
+
"external/lib/a.cpp", "requests/packages/urllib3/a.py", "pip/_vendor/six.py", "botocore/vendored/requests/a.py"):
|
|
137
137
|
self.assertTrue(filetypes.is_vendor_path(path), path)
|
|
138
|
-
for path in ("vendors.py", "src/vendoring/a.py", "node/a.js", "externals.txt", "app/main.go"
|
|
138
|
+
for path in ("vendors.py", "src/vendoring/a.py", "node/a.js", "externals.txt", "app/main.go",
|
|
139
|
+
"packages/runtime-core/src/renderer.ts", "packages-private/x.ts"): # a monorepo's own packages/ at the root
|
|
139
140
|
self.assertFalse(filetypes.is_vendor_path(path), path)
|
|
140
141
|
|
|
141
142
|
|
|
@@ -47,7 +47,7 @@ class SecretsFound(unittest.TestCase):
|
|
|
47
47
|
self.assertIn("1 distinct value in 2 places: generic-api-key in app/settings.py (c1, c2)", crit["detail"])
|
|
48
48
|
self.assertIn("Rotate", crit["advice"])
|
|
49
49
|
self.assertIn(".betterleaksignore", crit["advice"])
|
|
50
|
-
self.assertEqual(warn["title"], "2 secret(s) only in test, example or documentation files")
|
|
50
|
+
self.assertEqual(warn["title"], "2 secret(s) only in test, example, vendored or documentation files")
|
|
51
51
|
self.assertIn("2 distinct values in 3 places", warn["detail"])
|
|
52
52
|
self.assertIn("tests/data/a.html and 1 other file", warn["detail"])
|
|
53
53
|
self.assertIn(".betterleaksignore", warn["advice"])
|
|
@@ -62,15 +62,22 @@ class SecretsFound(unittest.TestCase):
|
|
|
62
62
|
self.row("h3", "pkg/testdata/creds.yaml", "c3")])
|
|
63
63
|
f = findings.secrets_found(r)
|
|
64
64
|
self.assertEqual([x["severity"] for x in f], ["warning"])
|
|
65
|
-
self.assertEqual(f[0]["title"], "3 secret(s) only in test, example or documentation files")
|
|
65
|
+
self.assertEqual(f[0]["title"], "3 secret(s) only in test, example, vendored or documentation files")
|
|
66
66
|
r = report(secrets=[self.row("h1", "examples/app.py", "c1"), self.row("h1", "app/config.py", "c2")])
|
|
67
67
|
self.assertEqual([x["severity"] for x in findings.secrets_found(r)], ["critical"], "the same value in source is a leak")
|
|
68
68
|
|
|
69
|
+
def test_a_value_only_in_vendored_code_is_a_warning(self):
|
|
70
|
+
# oauthlib's RFC test vectors inside requests/packages/: upstream's specimen, not this repository's credential
|
|
71
|
+
r = report(secrets=[self.row("h1", "requests/packages/oauthlib/oauth1/rfc5849/parameters.py", "9576518")])
|
|
72
|
+
f = findings.secrets_found(r)
|
|
73
|
+
self.assertEqual([x["severity"] for x in f], ["warning"])
|
|
74
|
+
self.assertIn("vendored", f[0]["title"])
|
|
75
|
+
|
|
69
76
|
def test_a_value_only_in_documentation_is_a_warning_that_says_template(self):
|
|
70
77
|
r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508")])
|
|
71
78
|
f = findings.secrets_found(r)
|
|
72
79
|
self.assertEqual([x["severity"] for x in f], ["warning"])
|
|
73
|
-
self.assertEqual(f[0]["title"], "1 secret(s) only in test, example or documentation files")
|
|
80
|
+
self.assertEqual(f[0]["title"], "1 secret(s) only in test, example, vendored or documentation files")
|
|
74
81
|
self.assertIn("fixtures or templates", f[0]["advice"])
|
|
75
82
|
r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508"), self.row("h1", "app/config.py", "c2")])
|
|
76
83
|
self.assertEqual([x["severity"] for x in findings.secrets_found(r)], ["critical"], "the same value in source is a leak")
|
|
@@ -283,6 +290,44 @@ class TightCoupling(unittest.TestCase):
|
|
|
283
290
|
self.assertEqual(findings.tight_coupling(report()), [])
|
|
284
291
|
|
|
285
292
|
|
|
293
|
+
class ReleasePlumbing(unittest.TestCase):
|
|
294
|
+
def test_a_version_file_or_manifest_does_not_dominate_the_churn(self):
|
|
295
|
+
revs = [{"entity": "setup.py", "n-revs": 184}, {"entity": "requests/models.py", "n-revs": 60}, {"entity": "requests/api.py", "n-revs": 20}]
|
|
296
|
+
f = findings.hotspot_dominance(report(revisions=revs))
|
|
297
|
+
self.assertIn("requests/models.py changed 60 times", f[0]["detail"])
|
|
298
|
+
self.assertNotIn("setup.py", f[0]["detail"])
|
|
299
|
+
|
|
300
|
+
def test_a_manifest_is_not_a_bug_magnet(self):
|
|
301
|
+
fixes = [{"entity": "package.json", "n-fixes": 20, "last-fix": "2026-09-01", "recent-fixes": 6},
|
|
302
|
+
{"entity": "lib/reply.js", "n-fixes": 10, "last-fix": "2026-09-01", "recent-fixes": 4}]
|
|
303
|
+
f = findings.bug_magnets(report(fixes=fixes))
|
|
304
|
+
self.assertIn("lib/reply.js", f[0]["detail"])
|
|
305
|
+
self.assertNotIn("package.json", f[0]["detail"])
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class Dormant(unittest.TestCase):
|
|
309
|
+
def test_a_year_without_commits_is_a_warning_that_dates_the_last_one(self):
|
|
310
|
+
r = report()
|
|
311
|
+
r["meta"].update({"last_date": "2025-06-14", "now": "2026-09-17"})
|
|
312
|
+
f = findings.dormant(r)
|
|
313
|
+
self.assertEqual(f[0]["severity"], "warning")
|
|
314
|
+
self.assertEqual(f[0]["title"], "Dormant repository")
|
|
315
|
+
self.assertIn("No commits since 2025-06-14, 15 months ago.", f[0]["detail"])
|
|
316
|
+
self.assertIn("stopped", f[0]["advice"])
|
|
317
|
+
|
|
318
|
+
def test_a_recent_commit_is_not_dormant(self):
|
|
319
|
+
r = report()
|
|
320
|
+
r["meta"].update({"last_date": "2026-06-14", "now": "2026-09-17"})
|
|
321
|
+
self.assertEqual(findings.dormant(r), [])
|
|
322
|
+
self.assertEqual(findings.dormant(report()), [], "no dates, no finding")
|
|
323
|
+
|
|
324
|
+
def test_stale_files_are_not_reported_for_a_dormant_repository(self):
|
|
325
|
+
age = [{"entity": f"f{i}", "age-months": 15} for i in range(10)]
|
|
326
|
+
r = report(age=age)
|
|
327
|
+
r["meta"].update({"last_date": "2025-06-14", "now": "2026-09-17"})
|
|
328
|
+
self.assertEqual(findings.stale_files(r), [], "every file is untouched because nothing is; the dormancy finding says so")
|
|
329
|
+
|
|
330
|
+
|
|
286
331
|
class StaleFiles(unittest.TestCase):
|
|
287
332
|
def test_info_when_a_third_untouched_for_a_year(self):
|
|
288
333
|
age = [{"entity": f"f{i}", "age-months": 12} for i in range(4)] + [{"entity": "g", "age-months": 0} for _ in range(6)]
|
|
@@ -26,6 +26,14 @@ class Merge(unittest.TestCase):
|
|
|
26
26
|
merged = {m["name"]: m["commits"] for m in identity.merge(ids)}
|
|
27
27
|
self.assertEqual(merged, {"KaKa": 123, "namusyaka": 188, "Li Yu": 8})
|
|
28
28
|
|
|
29
|
+
def test_a_handle_that_is_one_distinctive_word_of_a_fuller_name_is_the_same_person(self):
|
|
30
|
+
ids = [{"name": "Junegunn Choi", "email": "junegunn.c@a.com", "commits": 2924}, {"name": "junegunn", "email": "junegunn@b.com", "commits": 123},
|
|
31
|
+
{"name": "Sam Altman", "email": "sam@a.com", "commits": 5}, {"name": "sam", "email": "sam@b.com", "commits": 2},
|
|
32
|
+
{"name": "Kevin Brown", "email": "kb@a.com", "commits": 76}, {"name": "Kevin", "email": "k@b.com", "commits": 21}]
|
|
33
|
+
merged = {m["name"]: m["commits"] for m in identity.merge(ids)}
|
|
34
|
+
self.assertEqual(merged, {"Junegunn Choi": 3047, "Sam Altman": 5, "sam": 2, "Kevin Brown": 76, "Kevin": 21},
|
|
35
|
+
"a short or common first name is not distinctive enough")
|
|
36
|
+
|
|
29
37
|
def test_a_bare_common_first_name_is_not_enough(self):
|
|
30
38
|
ids = [{"name": "Jean", "email": "jean@a.com", "commits": 24}, {"name": "Jean", "email": "jean@b.com", "commits": 18},
|
|
31
39
|
{"name": "Alex", "email": "alex@a.com", "commits": 3}, {"name": "alex", "email": "alex@b.com", "commits": 2}]
|
|
@@ -76,7 +84,8 @@ class IsBot(unittest.TestCase):
|
|
|
76
84
|
for name, email in [("renovate[bot]", "29139614+renovate[bot]@users.noreply.github.com"),
|
|
77
85
|
("github-actions[bot]", "41898282+github-actions[bot]@users.noreply.github.com"),
|
|
78
86
|
("dependabot[bot]", "support@github.com"), ("Dependabot", "dependabot@example.com"),
|
|
79
|
-
("Renovate Bot", "bot@renovateapp.com"), ("GitHub Actions", "actions@github.com")
|
|
87
|
+
("Renovate Bot", "bot@renovateapp.com"), ("GitHub Actions", "actions@github.com"),
|
|
88
|
+
("Copilot", "198982749+Copilot@users.noreply.github.com"), ("Cursor Agent", "cursoragent@cursor.com")]:
|
|
80
89
|
self.assertTrue(identity.is_bot(name, email), (name, email))
|
|
81
90
|
|
|
82
91
|
def test_people_are_not_bots(self):
|
|
@@ -66,6 +66,14 @@ class Placeholder(unittest.TestCase):
|
|
|
66
66
|
for value in ["hello123", "secret-9f8a7b6c5d4e", "s3cr3t!Passw0rd", "foobarbaz2024"]:
|
|
67
67
|
self.assertFalse(leaks.is_placeholder(value), "a word inside other material is not a placeholder: " + value)
|
|
68
68
|
|
|
69
|
+
def test_references_to_an_environment_variable_are_placeholders(self):
|
|
70
|
+
# fzf's notarisation config: `password = "@env:AC_PASSWORD"`; goreleaser: `{{.Env.MACOS_SIGN_PASSWORD}}`
|
|
71
|
+
for value in ["@env:AC_PASSWORD", "{{.Env.MACOS_SIGN_PASSWORD}}", "${DB_PASSWORD}", "$DB_PASSWORD", "$(cat ~/.secret)", "%APPDATA_KEY%",
|
|
72
|
+
"{{ secrets.API_TOKEN }}", "<%= ENV['KEY'] %>", "process.env.API_KEY", "os.environ['API_KEY']", "os.environ.get('K')", "ENV['SECRET']"]:
|
|
73
|
+
self.assertTrue(leaks.is_placeholder(value), value)
|
|
74
|
+
for value in ["p@ssw0rd!", "AKIA" + "X" * 16, "env-9f8a7b6c5d4e3f2a", "${not closed", "hello$world"]:
|
|
75
|
+
self.assertFalse(leaks.is_placeholder(value), value)
|
|
76
|
+
|
|
69
77
|
def test_anything_else_is_taken_seriously(self):
|
|
70
78
|
# built at runtime: a literal in these shapes would trip secret scanners on this very file
|
|
71
79
|
key_id, long_key = "AKIA" + "X" * 16, "6L" + "x" * 38
|
|
@@ -48,6 +48,18 @@ class ParseLog(unittest.TestCase):
|
|
|
48
48
|
self.assertEqual(commits[0]["files"], [("src/a.py", 3, 1), ("src/b.py", 2, 0)])
|
|
49
49
|
self.assertEqual(commits[1]["files"][2], ("img/logo.png", 0, 0))
|
|
50
50
|
|
|
51
|
+
def test_renames_are_followed_to_the_new_path_and_a_pure_move_adds_no_lines(self):
|
|
52
|
+
# `git log -M --numstat` spells a rename three ways; the mover is not the owner of what moved
|
|
53
|
+
log = ("--d63e94f5--2023-08-13T10:00:00+00:00--Nate--Move to src layout\n"
|
|
54
|
+
"0\t0\t{requests => src/requests}/__init__.py\n"
|
|
55
|
+
"4\t7\tSECURITY.md => .github/SECURITY.md\n"
|
|
56
|
+
"0\t0\tCODE_OF_CONDUCT.md => .github/CODE_OF_CONDUCT.md\n"
|
|
57
|
+
"2\t0\tsrc/requests/{models.py => models_v2.py}\n"
|
|
58
|
+
"1\t1\tMakefile\n")
|
|
59
|
+
commits = maat.parse_log(log, types=None)
|
|
60
|
+
self.assertEqual(commits[0]["files"], [("src/requests/__init__.py", 0, 0), (".github/SECURITY.md", 4, 7),
|
|
61
|
+
(".github/CODE_OF_CONDUCT.md", 0, 0), ("src/requests/models_v2.py", 2, 0), ("Makefile", 1, 1)])
|
|
62
|
+
|
|
51
63
|
def test_subjects_with_exotic_line_break_characters_do_not_split_the_log(self):
|
|
52
64
|
# U+2028 and form feed are line breaks to str.splitlines but not to git
|
|
53
65
|
text = "--x--2026-05-04T10:00:00+00:00--Ann--Fix\u2028broken\x0cthing\n1\t0\tf.py\n"
|
|
@@ -334,6 +334,17 @@ class Report(unittest.TestCase):
|
|
|
334
334
|
full = rendered(r, [], width=200, full=True)
|
|
335
335
|
self.assertIn("validate10", full)
|
|
336
336
|
|
|
337
|
+
def test_default_hotspots_hide_release_plumbing_and_say_so(self):
|
|
338
|
+
r = sample_report()
|
|
339
|
+
r["size"]["files"].update({"setup.py": {"code": 6, "complexity": 0}, "version.go": {"code": 2, "complexity": 0}})
|
|
340
|
+
r["revisions"] += [{"entity": "setup.py", "n-revs": 184}, {"entity": "version.go", "n-revs": 29}]
|
|
341
|
+
hot = rendered(r, [], width=200)
|
|
342
|
+
hot = hot[hot.index("◆ Hotspots"):hot.index("Change coupling")]
|
|
343
|
+
self.assertNotIn("setup.py", hot)
|
|
344
|
+
self.assertIn("2 release files hidden; --full shows them", hot)
|
|
345
|
+
full = rendered(r, [], width=200, full=True)
|
|
346
|
+
self.assertIn("setup.py", full[full.index("◆ Hotspots"):])
|
|
347
|
+
|
|
337
348
|
def test_default_coupling_hides_release_plumbing_pairs_and_says_so(self):
|
|
338
349
|
r = sample_report()
|
|
339
350
|
for f in ("lib/version.rb", "contrib/version.rb", "Gemfile", "Gemfile.lock"):
|
|
@@ -192,6 +192,8 @@ class Plan(unittest.TestCase):
|
|
|
192
192
|
self.assertEqual(by["scc"]["stdout"], "/o/size.json")
|
|
193
193
|
self.assertIn("--by-file", by["scc"]["argv"])
|
|
194
194
|
self.assertIn("--use-mailmap", by["git-log"]["argv"])
|
|
195
|
+
self.assertIn("-M", by["git-log"]["argv"], "renames are followed so a move to src/ credits nobody with the moved lines")
|
|
196
|
+
self.assertNotIn("--no-renames", by["git-log"]["argv"])
|
|
195
197
|
self.assertEqual(by["git-log"]["argv"][:4], ["git", "-c", "core.quotePath=false", "log"], "non-ASCII paths must not be octal-escaped and quoted")
|
|
196
198
|
|
|
197
199
|
def test_function_metrics_step_is_optional_and_runs_the_bundled_script(self):
|
|
@@ -53,6 +53,14 @@ class Risks(unittest.TestCase):
|
|
|
53
53
|
self.assertNotIn("core/gone.py", files, "no longer in the tree")
|
|
54
54
|
self.assertNotIn("core/once.py", files, "changed once")
|
|
55
55
|
|
|
56
|
+
def test_release_plumbing_is_not_on_the_list(self):
|
|
57
|
+
r = report()
|
|
58
|
+
r["size"]["files"].update({"setup.py": {"code": 6, "complexity": 0}, "version.go": {"code": 2, "complexity": 0}, "Makefile": {"code": 21, "complexity": 0}})
|
|
59
|
+
r["revisions"] = [{"entity": "setup.py", "n-revs": 184}, {"entity": "version.go", "n-revs": 29}, {"entity": "Makefile", "n-revs": 131},
|
|
60
|
+
{"entity": "core/parser.py", "n-revs": 40}]
|
|
61
|
+
files = sorted(x["file"] for x in watch.risks(r))
|
|
62
|
+
self.assertEqual(files, ["Makefile", "core/parser.py"], "a version file or a manifest changes on every release, not where the next bug lands")
|
|
63
|
+
|
|
56
64
|
def test_test_companions_and_weak_pairs_are_not_reasons(self):
|
|
57
65
|
top = watch.risks(report())[0]
|
|
58
66
|
coupling = [r for r in top["reasons"] if r.startswith("changes with")][0]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|