gitmole 0.5.0__tar.gz → 0.6.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitmole-0.5.0 → gitmole-0.6.2}/PKG-INFO +6 -3
- {gitmole-0.5.0 → gitmole-0.6.2}/README.md +5 -2
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/__init__.py +1 -1
- gitmole-0.6.2/gitmole/clean.py +90 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/cli.py +72 -21
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/filetypes.py +9 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/findings.py +41 -15
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/leaks.py +20 -7
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/render.py +13 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/run.py +3 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole.egg-info/PKG-INFO +6 -3
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole.egg-info/SOURCES.txt +2 -0
- gitmole-0.6.2/tests/test_clean.py +109 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_cli.py +179 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_filetypes.py +6 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_findings.py +42 -1
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_leaks.py +25 -1
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_render.py +12 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_run.py +8 -1
- {gitmole-0.5.0 → gitmole-0.6.2}/LICENSE +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/__main__.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/backtest.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/banner.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/blame.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/functions.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/hotspots.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/identity.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/knowledge.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/load.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/loss.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/maat.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/textfmt.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/trend.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole/watch.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/pyproject.toml +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/setup.cfg +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_backtest.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_banner.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_blame.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_functions.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_golden.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_hotspots.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_identity.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_knowledge.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_load.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_loss.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_maat.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_packaging.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_textfmt.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_trend.py +0 -0
- {gitmole-0.5.0 → gitmole-0.6.2}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.2
|
|
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
|
|
@@ -63,6 +63,7 @@ gitmole . --json report.json # every table, the watch list and the fin
|
|
|
63
63
|
gitmole . --fail-on warning # exit 3 if any finding is a warning or worse
|
|
64
64
|
gitmole . --risk main --risk-threshold 5 # exit 3 if the files changed since main are too risky
|
|
65
65
|
gitmole . --since 2y --full # the current team, every row and column
|
|
66
|
+
gitmole --clean # list what gitmole left behind, delete on a yes
|
|
66
67
|
```
|
|
67
68
|
|
|
68
69
|
A CI job that runs `gitmole . --fail-on critical --markdown - >> "$GITHUB_STEP_SUMMARY"`
|
|
@@ -144,8 +145,10 @@ Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/bl
|
|
|
144
145
|
|
|
145
146
|
- Everything is offline except the optional clone step, which uses your
|
|
146
147
|
existing gh auth. None of the tools send data anywhere.
|
|
147
|
-
- Remote targets are cloned into a fresh temp directory
|
|
148
|
-
only read, but the log export and the
|
|
148
|
+
- Remote targets are cloned into a fresh temp directory that is removed when
|
|
149
|
+
the run ends. Local clones are only read, but the log export and the
|
|
150
|
+
secrets scan touch all branches. `gitmole --clean` lists every directory
|
|
151
|
+
gitmole created and deletes them after a y/N question.
|
|
149
152
|
- Secret values never reach the output directory. betterleaks reports to
|
|
150
153
|
gitmole in memory, and gitmole stores a short keyed hash in place of the
|
|
151
154
|
value, the matched text and the commit message. The key is random, made
|
|
@@ -43,6 +43,7 @@ gitmole . --json report.json # every table, the watch list and the fin
|
|
|
43
43
|
gitmole . --fail-on warning # exit 3 if any finding is a warning or worse
|
|
44
44
|
gitmole . --risk main --risk-threshold 5 # exit 3 if the files changed since main are too risky
|
|
45
45
|
gitmole . --since 2y --full # the current team, every row and column
|
|
46
|
+
gitmole --clean # list what gitmole left behind, delete on a yes
|
|
46
47
|
```
|
|
47
48
|
|
|
48
49
|
A CI job that runs `gitmole . --fail-on critical --markdown - >> "$GITHUB_STEP_SUMMARY"`
|
|
@@ -124,8 +125,10 @@ Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/bl
|
|
|
124
125
|
|
|
125
126
|
- Everything is offline except the optional clone step, which uses your
|
|
126
127
|
existing gh auth. None of the tools send data anywhere.
|
|
127
|
-
- Remote targets are cloned into a fresh temp directory
|
|
128
|
-
only read, but the log export and the
|
|
128
|
+
- Remote targets are cloned into a fresh temp directory that is removed when
|
|
129
|
+
the run ends. Local clones are only read, but the log export and the
|
|
130
|
+
secrets scan touch all branches. `gitmole --clean` lists every directory
|
|
131
|
+
gitmole created and deletes them after a y/N question.
|
|
129
132
|
- Secret values never reach the output directory. betterleaks reports to
|
|
130
133
|
gitmole in memory, and gitmole stores a short keyed hash in place of the
|
|
131
134
|
value, the matched text and the commit message. The key is random, made
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""What gitmole leaves behind, found by looking: temp clones in the temp folder and analysis-* output
|
|
2
|
+
directories under a base. Pure functions; the CLI prints, asks and reports."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
from . import run
|
|
11
|
+
|
|
12
|
+
TEMP_PREFIX = "gitmole-" # tempfile.mkdtemp(prefix=...) in cli._resolve_target and cli._portfolio
|
|
13
|
+
OUT_PREFIX = "analysis-" # run.output_dir and cli._portfolio
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def temp_dir() -> str:
|
|
17
|
+
"""Where the temp clones went: mkdtemp(dir=os.environ.get("TMPDIR")) resolves the same way."""
|
|
18
|
+
return os.environ.get("TMPDIR") or tempfile.gettempdir()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_output(path: str) -> bool:
|
|
22
|
+
return os.path.isdir(path) and os.path.isfile(os.path.join(path, "meta.json"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _whole_portfolio(path: str) -> bool:
|
|
26
|
+
"""A portfolio parent is gitmole's as a whole when every directory in it is a repo output and every
|
|
27
|
+
other entry is a portfolio.* export or a dotfile."""
|
|
28
|
+
names = os.listdir(path)
|
|
29
|
+
dirs = [n for n in names if os.path.isdir(os.path.join(path, n))]
|
|
30
|
+
rest = [n for n in names if n not in dirs and not n.startswith(".")]
|
|
31
|
+
return bool(dirs) and all(_is_output(os.path.join(path, n)) for n in dirs) and all(n.startswith("portfolio.") for n in rest)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _outputs(base: str) -> list:
|
|
35
|
+
candidates = sorted(glob.glob(os.path.join(base, OUT_PREFIX + "*")))
|
|
36
|
+
if os.path.isdir(os.path.join(base, ".git")):
|
|
37
|
+
candidates.append(run.output_dir("path", base, None))
|
|
38
|
+
found = []
|
|
39
|
+
for path in candidates:
|
|
40
|
+
if not os.path.isdir(path):
|
|
41
|
+
continue
|
|
42
|
+
if _is_output(path) or _whole_portfolio(path):
|
|
43
|
+
found.append(path)
|
|
44
|
+
else:
|
|
45
|
+
found += [c for c in sorted(glob.glob(os.path.join(path, "*"))) if _is_output(c)]
|
|
46
|
+
return sorted(set(found))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _clones(tmp: str) -> list:
|
|
50
|
+
paths = [p for p in glob.glob(os.path.join(tmp, TEMP_PREFIX + "*")) if os.path.isdir(p)]
|
|
51
|
+
return sorted(paths, key=lambda p: (os.path.getmtime(p), p))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def tree_size(path: str) -> int:
|
|
55
|
+
total = 0
|
|
56
|
+
for root, _dirs, files in os.walk(path):
|
|
57
|
+
for name in files:
|
|
58
|
+
try:
|
|
59
|
+
total += os.lstat(os.path.join(root, name)).st_size
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
return total
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def find(base: str, tmp: str) -> list:
|
|
66
|
+
"""(path, bytes, mtime) for every directory gitmole left behind: temp clones oldest first, then
|
|
67
|
+
output directories under base sorted by path."""
|
|
68
|
+
paths = _clones(tmp) + _outputs(os.path.abspath(base))
|
|
69
|
+
return [(p, tree_size(p), os.path.getmtime(p)) for p in paths]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def human(n) -> str:
|
|
73
|
+
units = ["B", "kB", "MB", "GB", "TB"]
|
|
74
|
+
x, i = float(n), 0
|
|
75
|
+
while x >= 1000 and i < len(units) - 1:
|
|
76
|
+
x /= 1000
|
|
77
|
+
i += 1
|
|
78
|
+
if i == 0:
|
|
79
|
+
return f"{int(x)} B"
|
|
80
|
+
return f"{x:.1f} {units[i]}" if x < 10 else f"{x:.0f} {units[i]}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def remove(paths: list) -> list:
|
|
84
|
+
"""rmtree each path, best effort; returns the ones still present afterwards."""
|
|
85
|
+
failed = []
|
|
86
|
+
for p in paths:
|
|
87
|
+
shutil.rmtree(p, ignore_errors=True)
|
|
88
|
+
if os.path.exists(p):
|
|
89
|
+
failed.append(p)
|
|
90
|
+
return failed
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
"""Command line entry point: gitmole <path | owner/repo | url> [--out DIR] [--no-run]."""
|
|
1
|
+
"""Command line entry point: gitmole <path | owner/repo | url> [--out DIR] [--no-run], or gitmole --clean [DIR]."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
5
|
import os
|
|
6
|
+
import shutil
|
|
6
7
|
import signal
|
|
7
8
|
import sys
|
|
8
9
|
import tempfile
|
|
@@ -19,7 +20,7 @@ from . import __version__, banner, filetypes, findings, load, loss, run
|
|
|
19
20
|
|
|
20
21
|
def parse_args(argv):
|
|
21
22
|
p = argparse.ArgumentParser(prog="gitmole", description="Analyse a git repository offline and print a report.")
|
|
22
|
-
p.add_argument("target", help="local clone path, owner/repo, or git URL")
|
|
23
|
+
p.add_argument("target", nargs="?", help="local clone path, owner/repo, or git URL; with --clean, the directory to look in (default .)")
|
|
23
24
|
p.add_argument("--out", help="output directory (default: analysis-<repo> next to the clone, or in cwd for remote targets)")
|
|
24
25
|
p.add_argument("--no-run", action="store_true", help="skip the tools; re-render the report from an existing output directory")
|
|
25
26
|
p.add_argument("--workers", type=int, default=6, help="how many tools to run at once")
|
|
@@ -34,6 +35,8 @@ def parse_args(argv):
|
|
|
34
35
|
p.add_argument("--gone", type=int, default=loss.DEFAULT_MONTHS, metavar="MONTHS", help="a person with no commits this many months before the last commit counts as gone (default 12)")
|
|
35
36
|
p.add_argument("--file-types", metavar="LIST", help="comma-separated extensions to treat as code (default: a built-in source list), or 'all'")
|
|
36
37
|
p.add_argument("--list-file-types", action="store_true", help="list the file types in the repository, with counts and whether they count as code, then exit")
|
|
38
|
+
p.add_argument("--clean", action="store_true", help="list the directories gitmole created (temp clones, analysis-* under the target) and delete them after a y/N question, then exit")
|
|
39
|
+
p.add_argument("--yes", action="store_true", help="with --clean: delete without asking")
|
|
37
40
|
p.add_argument("--duplicates", action="store_true", help="also look for duplicated code blocks (minutes and gigabytes on a large repo; function metrics alone take seconds)")
|
|
38
41
|
p.add_argument("--full", action="store_true", help="every column and every row in the terminal report, and the test files the default tables hide (the default is the tighter, readable one)")
|
|
39
42
|
p.add_argument("--json", metavar="PATH", help="write the report and findings as JSON to PATH, or - for stdout")
|
|
@@ -54,7 +57,7 @@ def interrupt(*_):
|
|
|
54
57
|
|
|
55
58
|
|
|
56
59
|
def main(argv=None, console: Console = None, tool_check=run.missing_tools, planner=run.plan, estimator=run.estimate_blames,
|
|
57
|
-
lister=run.list_repos, cloner=run.clone, lizard_check=run.has_lizard) -> int:
|
|
60
|
+
lister=run.list_repos, cloner=run.clone, lizard_check=run.has_lizard, ask=None) -> int:
|
|
58
61
|
global _control
|
|
59
62
|
_control = run.Control()
|
|
60
63
|
if threading.current_thread() is threading.main_thread():
|
|
@@ -65,6 +68,8 @@ def main(argv=None, console: Console = None, tool_check=run.missing_tools, plann
|
|
|
65
68
|
rc = _check_args(args, err)
|
|
66
69
|
if rc is not None:
|
|
67
70
|
return rc
|
|
71
|
+
if args.clean:
|
|
72
|
+
return _clean(args, console, ask or (lambda q: console.input(q, markup=False)))
|
|
68
73
|
# When an export goes to stdout, everything else (banner, progress, report) moves to stderr.
|
|
69
74
|
quiet = "-" in (args.json, args.markdown)
|
|
70
75
|
ui = Console(stderr=True) if quiet else console
|
|
@@ -110,6 +115,9 @@ def main(argv=None, console: Console = None, tool_check=run.missing_tools, plann
|
|
|
110
115
|
except NoCommits as e:
|
|
111
116
|
err.print(f"[red]{e}[/red]")
|
|
112
117
|
return 2
|
|
118
|
+
finally:
|
|
119
|
+
if kind == "remote":
|
|
120
|
+
shutil.rmtree(os.path.dirname(repo_dir), ignore_errors=True) # the temp clone; nothing reads it after the run
|
|
113
121
|
return _render(out_dir, console, ui, args, err)
|
|
114
122
|
|
|
115
123
|
|
|
@@ -117,7 +125,9 @@ def _check_args(args, err, kind=None) -> int | None:
|
|
|
117
125
|
"""The argument combinations that cannot work, in one place: 2 and a message, or None. Called
|
|
118
126
|
once on the arguments alone, then again with the target's `kind` for the checks that need it."""
|
|
119
127
|
if kind is None:
|
|
120
|
-
bad = "--
|
|
128
|
+
bad = ("--yes needs --clean" if args.yes and not args.clean else
|
|
129
|
+
"target required" if args.target is None and not args.clean else
|
|
130
|
+
"--risk-threshold needs --risk" if args.risk_threshold is not None and not args.risk else None)
|
|
121
131
|
elif kind == "path":
|
|
122
132
|
bad = None
|
|
123
133
|
else:
|
|
@@ -165,6 +175,43 @@ def _no_run(args, console, ui, err) -> int:
|
|
|
165
175
|
return _render(out_dir, console, ui, args, err)
|
|
166
176
|
|
|
167
177
|
|
|
178
|
+
def _clean(args, console: Console, ask) -> int:
|
|
179
|
+
"""Handle --clean: list what gitmole left behind, ask once, remove. ask(prompt) returns the answer."""
|
|
180
|
+
from . import clean, render
|
|
181
|
+
|
|
182
|
+
found = clean.find(args.target or ".", clean.temp_dir())
|
|
183
|
+
if not found:
|
|
184
|
+
console.print("nothing to clean")
|
|
185
|
+
return 0
|
|
186
|
+
total = sum(size for _, size, _ in found)
|
|
187
|
+
rows = [(path, clean.human(size), time.strftime("%Y-%m-%d", time.localtime(mtime))) for path, size, mtime in found]
|
|
188
|
+
sec = render._section("Left behind", [("directory", render.PATH), ("size", render.RIGHT), ("modified", {})], rows,
|
|
189
|
+
caption=f"{_dirs(len(found))}, {clean.human(total)} in all")
|
|
190
|
+
render.print_section(console, sec)
|
|
191
|
+
console.print(Text(""))
|
|
192
|
+
if not args.yes:
|
|
193
|
+
if not console.is_terminal:
|
|
194
|
+
console.print("[red]--clean needs a terminal to confirm;[/red] pass --yes to skip the question")
|
|
195
|
+
return 2
|
|
196
|
+
try:
|
|
197
|
+
answer = ask(f"Delete {_dirs(len(found))} ({clean.human(total)})? [y/N] ")
|
|
198
|
+
except EOFError:
|
|
199
|
+
answer = ""
|
|
200
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
201
|
+
console.print("kept")
|
|
202
|
+
return 0
|
|
203
|
+
failed = clean.remove([p for p, _, _ in found])
|
|
204
|
+
removed = [(p, size) for p, size, _ in found if p not in failed]
|
|
205
|
+
console.print(f"removed {_dirs(len(removed))} ({clean.human(sum(s for _, s in removed))})")
|
|
206
|
+
for p in failed:
|
|
207
|
+
console.print(f"[red]could not remove[/red] {p}", soft_wrap=True)
|
|
208
|
+
return 1 if failed else 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _dirs(n: int) -> str:
|
|
212
|
+
return f"{n} director{'y' if n == 1 else 'ies'}"
|
|
213
|
+
|
|
214
|
+
|
|
168
215
|
def _resolve_target(kind, target, args, console, ui, err, planner, estimator, lister, cloner):
|
|
169
216
|
"""Resolve the classified target to (repo_dir, out_dir) for _analyse, or a final return code for the
|
|
170
217
|
org and clone-failure paths. Returns (rc, repo_dir, out_dir); rc is None unless main should return early."""
|
|
@@ -177,6 +224,7 @@ def _resolve_target(kind, target, args, console, ui, err, planner, estimator, li
|
|
|
177
224
|
try:
|
|
178
225
|
repo_dir = cloner(target, parent)
|
|
179
226
|
except run.GhError as e:
|
|
227
|
+
shutil.rmtree(parent, ignore_errors=True)
|
|
180
228
|
err.print(f"[red]could not clone {target}:[/red] {e}", soft_wrap=True)
|
|
181
229
|
return 2, None, None
|
|
182
230
|
else:
|
|
@@ -325,23 +373,26 @@ def _portfolio(owner: str, args, console: Console, ui: Console, planner, estimat
|
|
|
325
373
|
return 2
|
|
326
374
|
parent = tempfile.mkdtemp(prefix="gitmole-", dir=os.environ.get("TMPDIR"))
|
|
327
375
|
reports = []
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
376
|
+
try:
|
|
377
|
+
for i, name in enumerate(repos, 1):
|
|
378
|
+
ui.print(f"[bold]{name}[/bold] [dim]({i}/{len(repos)})[/dim]")
|
|
379
|
+
try:
|
|
380
|
+
repo_dir = cloner(f"{owner}/{name}", parent)
|
|
381
|
+
except run.GhError as e:
|
|
382
|
+
ui.print(f"[red]could not clone {owner}/{name}:[/red] {e}", soft_wrap=True)
|
|
383
|
+
continue
|
|
384
|
+
out_dir = os.path.join(base, name)
|
|
385
|
+
try:
|
|
386
|
+
_analyse(repo_dir, out_dir, args, ui, planner, estimator)
|
|
387
|
+
except Interrupted:
|
|
388
|
+
return 130
|
|
389
|
+
except NoCommits as e:
|
|
390
|
+
ui.print(f"[yellow]{name}:[/yellow] {e}; skipped")
|
|
391
|
+
continue
|
|
392
|
+
report = load.load_report(out_dir)
|
|
393
|
+
reports.append((name, report, findings.evaluate(report)))
|
|
394
|
+
finally:
|
|
395
|
+
shutil.rmtree(parent, ignore_errors=True) # the temp clones; nothing reads them after the run
|
|
345
396
|
|
|
346
397
|
def export_path(p):
|
|
347
398
|
return p if p == "-" or os.path.isabs(p) else os.path.join(base, p)
|
|
@@ -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]
|
|
@@ -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)]
|
|
@@ -37,6 +37,9 @@ _URL = re.compile(r"^(https?://|git@|ssh://)")
|
|
|
37
37
|
|
|
38
38
|
def classify_target(target: str) -> tuple:
|
|
39
39
|
if os.path.isdir(target):
|
|
40
|
+
probe = subprocess.run(["git", "-C", target, "rev-parse", "--git-dir"], capture_output=True, text=True)
|
|
41
|
+
if probe.returncode != 0:
|
|
42
|
+
raise ValueError(f"{target} is not a git repository; pass a clone, owner/repo or owner/*")
|
|
40
43
|
return ("path", os.path.abspath(target))
|
|
41
44
|
if _ORG.match(target):
|
|
42
45
|
return ("org", target[:-2])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.2
|
|
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
|
|
@@ -63,6 +63,7 @@ gitmole . --json report.json # every table, the watch list and the fin
|
|
|
63
63
|
gitmole . --fail-on warning # exit 3 if any finding is a warning or worse
|
|
64
64
|
gitmole . --risk main --risk-threshold 5 # exit 3 if the files changed since main are too risky
|
|
65
65
|
gitmole . --since 2y --full # the current team, every row and column
|
|
66
|
+
gitmole --clean # list what gitmole left behind, delete on a yes
|
|
66
67
|
```
|
|
67
68
|
|
|
68
69
|
A CI job that runs `gitmole . --fail-on critical --markdown - >> "$GITHUB_STEP_SUMMARY"`
|
|
@@ -144,8 +145,10 @@ Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/bl
|
|
|
144
145
|
|
|
145
146
|
- Everything is offline except the optional clone step, which uses your
|
|
146
147
|
existing gh auth. None of the tools send data anywhere.
|
|
147
|
-
- Remote targets are cloned into a fresh temp directory
|
|
148
|
-
only read, but the log export and the
|
|
148
|
+
- Remote targets are cloned into a fresh temp directory that is removed when
|
|
149
|
+
the run ends. Local clones are only read, but the log export and the
|
|
150
|
+
secrets scan touch all branches. `gitmole --clean` lists every directory
|
|
151
|
+
gitmole created and deletes them after a y/N question.
|
|
149
152
|
- Secret values never reach the output directory. betterleaks reports to
|
|
150
153
|
gitmole in memory, and gitmole stores a short keyed hash in place of the
|
|
151
154
|
value, the matched text and the commit message. The key is random, made
|
|
@@ -6,6 +6,7 @@ gitmole/__main__.py
|
|
|
6
6
|
gitmole/backtest.py
|
|
7
7
|
gitmole/banner.py
|
|
8
8
|
gitmole/blame.py
|
|
9
|
+
gitmole/clean.py
|
|
9
10
|
gitmole/cli.py
|
|
10
11
|
gitmole/filetypes.py
|
|
11
12
|
gitmole/findings.py
|
|
@@ -31,6 +32,7 @@ gitmole.egg-info/top_level.txt
|
|
|
31
32
|
tests/test_backtest.py
|
|
32
33
|
tests/test_banner.py
|
|
33
34
|
tests/test_blame.py
|
|
35
|
+
tests/test_clean.py
|
|
34
36
|
tests/test_cli.py
|
|
35
37
|
tests/test_filetypes.py
|
|
36
38
|
tests/test_findings.py
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from gitmole import clean
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _output(path):
|
|
10
|
+
os.makedirs(path, exist_ok=True)
|
|
11
|
+
with open(os.path.join(path, "meta.json"), "w") as fh:
|
|
12
|
+
fh.write("{}")
|
|
13
|
+
with open(os.path.join(path, "size.json"), "w") as fh:
|
|
14
|
+
fh.write("x" * 100)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Find(unittest.TestCase):
|
|
18
|
+
def test_output_directory_with_meta_is_found_and_one_without_is_not(self):
|
|
19
|
+
with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as tmp:
|
|
20
|
+
_output(os.path.join(d, "analysis-a"))
|
|
21
|
+
os.makedirs(os.path.join(d, "analysis-b"))
|
|
22
|
+
found = clean.find(d, tmp)
|
|
23
|
+
self.assertEqual([p for p, _, _ in found], [os.path.join(d, "analysis-a")])
|
|
24
|
+
|
|
25
|
+
def test_size_and_mtime_come_with_the_path(self):
|
|
26
|
+
with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as tmp:
|
|
27
|
+
_output(os.path.join(d, "analysis-a"))
|
|
28
|
+
[(path, size, mtime)] = clean.find(d, tmp)
|
|
29
|
+
self.assertEqual(size, 102) # meta.json "{}" plus 100 bytes
|
|
30
|
+
self.assertGreater(mtime, 0)
|
|
31
|
+
|
|
32
|
+
def test_whole_portfolio_is_listed_once(self):
|
|
33
|
+
with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as tmp:
|
|
34
|
+
pf = os.path.join(d, "analysis-acme")
|
|
35
|
+
_output(os.path.join(pf, "one"))
|
|
36
|
+
_output(os.path.join(pf, "two"))
|
|
37
|
+
with open(os.path.join(pf, "portfolio.md"), "w") as fh:
|
|
38
|
+
fh.write("# acme\n")
|
|
39
|
+
with open(os.path.join(pf, ".DS_Store"), "w") as fh:
|
|
40
|
+
fh.write("")
|
|
41
|
+
found = clean.find(d, tmp)
|
|
42
|
+
self.assertEqual([p for p, _, _ in found], [pf])
|
|
43
|
+
|
|
44
|
+
def test_partial_portfolio_lists_its_children(self):
|
|
45
|
+
with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as tmp:
|
|
46
|
+
pf = os.path.join(d, "analysis-acme")
|
|
47
|
+
_output(os.path.join(pf, "one"))
|
|
48
|
+
os.makedirs(os.path.join(pf, "notes"))
|
|
49
|
+
found = clean.find(d, tmp)
|
|
50
|
+
self.assertEqual([p for p, _, _ in found], [os.path.join(pf, "one")])
|
|
51
|
+
|
|
52
|
+
def test_temp_clones_come_first_oldest_first_and_only_from_tmp(self):
|
|
53
|
+
with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as tmp:
|
|
54
|
+
newer = os.path.join(tmp, "gitmole-bbb")
|
|
55
|
+
older = os.path.join(tmp, "gitmole-aaa")
|
|
56
|
+
os.makedirs(newer)
|
|
57
|
+
os.makedirs(older)
|
|
58
|
+
os.utime(older, (1, 1))
|
|
59
|
+
os.utime(newer, (2, 2))
|
|
60
|
+
os.makedirs(os.path.join(d, "gitmole-under-base"))
|
|
61
|
+
_output(os.path.join(d, "analysis-a"))
|
|
62
|
+
found = clean.find(d, tmp)
|
|
63
|
+
self.assertEqual([p for p, _, _ in found], [older, newer, os.path.join(d, "analysis-a")])
|
|
64
|
+
|
|
65
|
+
def test_a_clone_as_base_includes_its_own_output_next_to_it(self):
|
|
66
|
+
with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as tmp:
|
|
67
|
+
repo = os.path.join(d, "widgets")
|
|
68
|
+
subprocess.run(["git", "init", "-q", repo], check=True)
|
|
69
|
+
_output(os.path.join(d, "analysis-widgets"))
|
|
70
|
+
found = clean.find(repo, tmp)
|
|
71
|
+
self.assertEqual([p for p, _, _ in found], [os.path.join(d, "analysis-widgets")])
|
|
72
|
+
|
|
73
|
+
def test_nothing_found_is_an_empty_list(self):
|
|
74
|
+
with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as tmp:
|
|
75
|
+
self.assertEqual(clean.find(d, tmp), [])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Human(unittest.TestCase):
|
|
79
|
+
def test_boundaries(self):
|
|
80
|
+
self.assertEqual(clean.human(0), "0 B")
|
|
81
|
+
self.assertEqual(clean.human(999), "999 B")
|
|
82
|
+
self.assertEqual(clean.human(1200), "1.2 kB")
|
|
83
|
+
self.assertEqual(clean.human(1_200_000), "1.2 MB")
|
|
84
|
+
self.assertEqual(clean.human(340_000_000), "340 MB")
|
|
85
|
+
self.assertEqual(clean.human(2_100_000_000), "2.1 GB")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Remove(unittest.TestCase):
|
|
89
|
+
def test_removes_trees_and_reports_what_is_left(self):
|
|
90
|
+
with tempfile.TemporaryDirectory() as d:
|
|
91
|
+
gone = os.path.join(d, "analysis-a")
|
|
92
|
+
_output(gone)
|
|
93
|
+
failed = clean.remove([gone, os.path.join(d, "never-existed")])
|
|
94
|
+
self.assertFalse(os.path.exists(gone))
|
|
95
|
+
self.assertEqual(failed, [])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TempDir(unittest.TestCase):
|
|
99
|
+
def test_honours_tmpdir(self):
|
|
100
|
+
with tempfile.TemporaryDirectory() as d:
|
|
101
|
+
old = os.environ.get("TMPDIR")
|
|
102
|
+
os.environ["TMPDIR"] = d
|
|
103
|
+
try:
|
|
104
|
+
self.assertEqual(clean.temp_dir(), d)
|
|
105
|
+
finally:
|
|
106
|
+
if old is None:
|
|
107
|
+
del os.environ["TMPDIR"]
|
|
108
|
+
else:
|
|
109
|
+
os.environ["TMPDIR"] = old
|
|
@@ -330,6 +330,59 @@ class GhFailures(unittest.TestCase):
|
|
|
330
330
|
self.assertIn("repository not found", text)
|
|
331
331
|
|
|
332
332
|
|
|
333
|
+
class TempClones(unittest.TestCase):
|
|
334
|
+
"""A remote or portfolio run removes the temp parent it cloned into, whatever happened in between."""
|
|
335
|
+
|
|
336
|
+
def _cloner(self, parents):
|
|
337
|
+
def cloner(target, parent):
|
|
338
|
+
parents.append(parent)
|
|
339
|
+
d = os.path.join(parent, target.split("/")[-1])
|
|
340
|
+
os.makedirs(d)
|
|
341
|
+
_tiny_repo(d)
|
|
342
|
+
return d
|
|
343
|
+
return cloner
|
|
344
|
+
|
|
345
|
+
def _planner(self, argv):
|
|
346
|
+
return lambda repo, out, branch="HEAD", **kw: [{"name": "q", "argv": argv, "stdout": None, "deps": []}]
|
|
347
|
+
|
|
348
|
+
def test_remote_run_removes_its_clone_on_success(self):
|
|
349
|
+
parents = []
|
|
350
|
+
with tempfile.TemporaryDirectory() as work:
|
|
351
|
+
rc = cli.main(["acme/widgets", "--out", os.path.join(work, "out")], console=console(), tool_check=lambda **kw: [],
|
|
352
|
+
planner=self._planner(["true"]), estimator=lambda repo, interval, **kw: {"files": 1, "samples": 1, "blames": 1},
|
|
353
|
+
cloner=self._cloner(parents))
|
|
354
|
+
self.assertEqual(rc, 0)
|
|
355
|
+
self.assertEqual(len(parents), 1)
|
|
356
|
+
self.assertFalse(os.path.exists(parents[0]))
|
|
357
|
+
|
|
358
|
+
def test_remote_run_removes_its_clone_when_a_step_fails(self):
|
|
359
|
+
parents = []
|
|
360
|
+
with tempfile.TemporaryDirectory() as work:
|
|
361
|
+
cli.main(["acme/widgets", "--out", os.path.join(work, "out")], console=console(), tool_check=lambda **kw: [],
|
|
362
|
+
planner=self._planner(["false"]), estimator=lambda repo, interval, **kw: {"files": 1, "samples": 1, "blames": 1},
|
|
363
|
+
cloner=self._cloner(parents))
|
|
364
|
+
self.assertFalse(os.path.exists(parents[0]))
|
|
365
|
+
|
|
366
|
+
def test_failed_clone_leaves_no_parent(self):
|
|
367
|
+
parents = []
|
|
368
|
+
def cloner(target, parent):
|
|
369
|
+
parents.append(parent)
|
|
370
|
+
raise run.GhError("repository not found")
|
|
371
|
+
rc = cli.main(["acme/missing"], console=console(), tool_check=lambda **kw: [], cloner=cloner)
|
|
372
|
+
self.assertEqual(rc, 2)
|
|
373
|
+
self.assertFalse(os.path.exists(parents[0]))
|
|
374
|
+
|
|
375
|
+
def test_portfolio_run_removes_its_parent(self):
|
|
376
|
+
parents = []
|
|
377
|
+
with tempfile.TemporaryDirectory() as work:
|
|
378
|
+
rc = cli.main(["acme/*", "--out", os.path.join(work, "pf")], console=console(), tool_check=lambda **kw: [],
|
|
379
|
+
planner=self._planner(["true"]), estimator=lambda repo, interval, **kw: {"files": 1, "samples": 1, "blames": 1},
|
|
380
|
+
lister=lambda owner: ["one", "two"], cloner=self._cloner(parents))
|
|
381
|
+
self.assertEqual(rc, 0)
|
|
382
|
+
self.assertEqual(len(set(parents)), 1)
|
|
383
|
+
self.assertFalse(os.path.exists(parents[0]))
|
|
384
|
+
|
|
385
|
+
|
|
333
386
|
class FileTypes(unittest.TestCase):
|
|
334
387
|
def test_list_file_types_prints_the_tree_and_exits(self):
|
|
335
388
|
with tempfile.TemporaryDirectory() as d:
|
|
@@ -516,8 +569,20 @@ class Arguments(unittest.TestCase):
|
|
|
516
569
|
self.assertEqual(rc, 2)
|
|
517
570
|
self.assertIn("not a repo", c.export_text())
|
|
518
571
|
|
|
572
|
+
def test_non_repo_directory_is_reported_not_raised(self):
|
|
573
|
+
with tempfile.TemporaryDirectory() as d:
|
|
574
|
+
target = os.path.join(d, "checkouts")
|
|
575
|
+
os.mkdir(target)
|
|
576
|
+
c = console()
|
|
577
|
+
rc = cli.main([target], console=c, tool_check=lambda **kw: [])
|
|
578
|
+
created = os.listdir(d)
|
|
579
|
+
self.assertEqual(rc, 2)
|
|
580
|
+
self.assertIn("not a git repository", c.export_text())
|
|
581
|
+
self.assertEqual(created, ["checkouts"]) # no analysis-checkouts/ next to a bad target
|
|
582
|
+
|
|
519
583
|
def test_missing_tools_are_listed(self):
|
|
520
584
|
with tempfile.TemporaryDirectory() as d:
|
|
585
|
+
_tiny_repo(d)
|
|
521
586
|
c = console()
|
|
522
587
|
rc = cli.main([d], console=c, tool_check=lambda **kw: ["scc"])
|
|
523
588
|
text = c.export_text()
|
|
@@ -527,6 +592,120 @@ class Arguments(unittest.TestCase):
|
|
|
527
592
|
self.assertNotIn("jar", text)
|
|
528
593
|
|
|
529
594
|
|
|
595
|
+
class Clean(unittest.TestCase):
|
|
596
|
+
"""--clean lists what gitmole left behind and deletes on a yes. TMPDIR is pointed at a scratch dir so the
|
|
597
|
+
real temp folder is never listed or touched."""
|
|
598
|
+
|
|
599
|
+
def _with_tmp(self, fn):
|
|
600
|
+
with tempfile.TemporaryDirectory() as work, tempfile.TemporaryDirectory() as tmp:
|
|
601
|
+
old = os.environ.get("TMPDIR")
|
|
602
|
+
os.environ["TMPDIR"] = tmp
|
|
603
|
+
try:
|
|
604
|
+
return fn(work, tmp)
|
|
605
|
+
finally:
|
|
606
|
+
if old is None:
|
|
607
|
+
del os.environ["TMPDIR"]
|
|
608
|
+
else:
|
|
609
|
+
os.environ["TMPDIR"] = old
|
|
610
|
+
|
|
611
|
+
def _output(self, path):
|
|
612
|
+
os.makedirs(path)
|
|
613
|
+
with open(os.path.join(path, "meta.json"), "w") as fh:
|
|
614
|
+
fh.write("{}")
|
|
615
|
+
|
|
616
|
+
def test_nothing_to_clean(self):
|
|
617
|
+
def go(work, tmp):
|
|
618
|
+
c = console()
|
|
619
|
+
rc = cli.main(["--clean", work], console=c)
|
|
620
|
+
return rc, c.export_text()
|
|
621
|
+
rc, text = self._with_tmp(go)
|
|
622
|
+
self.assertEqual(rc, 0)
|
|
623
|
+
self.assertIn("nothing to clean", text)
|
|
624
|
+
|
|
625
|
+
def test_lists_then_keeps_on_anything_but_yes(self):
|
|
626
|
+
def go(work, tmp):
|
|
627
|
+
self._output(os.path.join(work, "analysis-a"))
|
|
628
|
+
os.makedirs(os.path.join(tmp, "gitmole-x"))
|
|
629
|
+
c = Console(file=io.StringIO(), width=120, record=True, force_terminal=True, color_system=None)
|
|
630
|
+
asked = []
|
|
631
|
+
rc = cli.main(["--clean", work], console=c, ask=lambda q: asked.append(q) or "n")
|
|
632
|
+
return rc, c.export_text(), asked, os.path.isdir(os.path.join(work, "analysis-a")), os.path.isdir(os.path.join(tmp, "gitmole-x"))
|
|
633
|
+
rc, text, asked, out_kept, tmp_kept = self._with_tmp(go)
|
|
634
|
+
self.assertEqual(rc, 0)
|
|
635
|
+
self.assertIn("Left behind", text)
|
|
636
|
+
self.assertIn("analysis-a", text)
|
|
637
|
+
self.assertIn("gitmole-x", text)
|
|
638
|
+
self.assertEqual(len(asked), 1)
|
|
639
|
+
self.assertIn("Delete 2 directories", asked[0])
|
|
640
|
+
self.assertIn("kept", text)
|
|
641
|
+
self.assertTrue(out_kept)
|
|
642
|
+
self.assertTrue(tmp_kept)
|
|
643
|
+
|
|
644
|
+
def test_yes_answer_removes_and_reports(self):
|
|
645
|
+
def go(work, tmp):
|
|
646
|
+
self._output(os.path.join(work, "analysis-a"))
|
|
647
|
+
os.makedirs(os.path.join(tmp, "gitmole-x"))
|
|
648
|
+
c = Console(file=io.StringIO(), width=120, record=True, force_terminal=True, color_system=None)
|
|
649
|
+
rc = cli.main(["--clean", work], console=c, ask=lambda q: "Y")
|
|
650
|
+
return rc, c.export_text(), os.listdir(work), os.listdir(tmp)
|
|
651
|
+
rc, text, work_left, tmp_left = self._with_tmp(go)
|
|
652
|
+
self.assertEqual(rc, 0)
|
|
653
|
+
self.assertIn("removed 2 directories", text)
|
|
654
|
+
self.assertEqual(work_left, [])
|
|
655
|
+
self.assertEqual(tmp_left, [])
|
|
656
|
+
|
|
657
|
+
def test_default_base_is_the_current_directory(self):
|
|
658
|
+
def go(work, tmp):
|
|
659
|
+
self._output(os.path.join(work, "analysis-a"))
|
|
660
|
+
here = os.getcwd()
|
|
661
|
+
os.chdir(work)
|
|
662
|
+
try:
|
|
663
|
+
c = console()
|
|
664
|
+
rc = cli.main(["--clean", "--yes"], console=c)
|
|
665
|
+
finally:
|
|
666
|
+
os.chdir(here)
|
|
667
|
+
return rc, os.listdir(work)
|
|
668
|
+
rc, left = self._with_tmp(go)
|
|
669
|
+
self.assertEqual(rc, 0)
|
|
670
|
+
self.assertEqual(left, [])
|
|
671
|
+
|
|
672
|
+
def test_no_terminal_lists_and_refuses_without_yes(self):
|
|
673
|
+
def go(work, tmp):
|
|
674
|
+
self._output(os.path.join(work, "analysis-a"))
|
|
675
|
+
c = console() # force_terminal=False
|
|
676
|
+
rc = cli.main(["--clean", work], console=c, ask=lambda q: self.fail("must not ask"))
|
|
677
|
+
return rc, c.export_text(), os.path.isdir(os.path.join(work, "analysis-a"))
|
|
678
|
+
rc, text, kept = self._with_tmp(go)
|
|
679
|
+
self.assertEqual(rc, 2)
|
|
680
|
+
self.assertIn("analysis-a", text)
|
|
681
|
+
self.assertIn("needs a terminal", text)
|
|
682
|
+
self.assertIn("--yes", text)
|
|
683
|
+
self.assertTrue(kept)
|
|
684
|
+
|
|
685
|
+
def test_yes_flag_skips_the_question(self):
|
|
686
|
+
def go(work, tmp):
|
|
687
|
+
self._output(os.path.join(work, "analysis-a"))
|
|
688
|
+
c = console()
|
|
689
|
+
rc = cli.main(["--clean", work, "--yes"], console=c, ask=lambda q: self.fail("must not ask"))
|
|
690
|
+
return rc, c.export_text(), os.listdir(work)
|
|
691
|
+
rc, text, left = self._with_tmp(go)
|
|
692
|
+
self.assertEqual(rc, 0)
|
|
693
|
+
self.assertIn("removed 1 directory", text)
|
|
694
|
+
self.assertEqual(left, [])
|
|
695
|
+
|
|
696
|
+
def test_yes_without_clean_is_an_error(self):
|
|
697
|
+
c = console()
|
|
698
|
+
rc = cli.main([".", "--yes"], console=c)
|
|
699
|
+
self.assertEqual(rc, 2)
|
|
700
|
+
self.assertIn("--yes needs --clean", c.export_text())
|
|
701
|
+
|
|
702
|
+
def test_no_target_without_clean_is_an_error(self):
|
|
703
|
+
c = console()
|
|
704
|
+
rc = cli.main([], console=c)
|
|
705
|
+
self.assertEqual(rc, 2)
|
|
706
|
+
self.assertIn("target required", c.export_text())
|
|
707
|
+
|
|
708
|
+
|
|
530
709
|
class Risk(unittest.TestCase):
|
|
531
710
|
def _repo(self, d):
|
|
532
711
|
import subprocess
|
|
@@ -91,6 +91,12 @@ class TestPaths(unittest.TestCase):
|
|
|
91
91
|
for path in ("src/contest.py", "gitmole/render.py", "attest/x.py", "latest.md"):
|
|
92
92
|
self.assertFalse(filetypes.is_test_path(path), path)
|
|
93
93
|
|
|
94
|
+
def test_documentation_files_and_directories(self):
|
|
95
|
+
for path in ("README.md", "docs/GA4-API-INTEGRATION.md", "doc/guide.rst", "NOTES.txt", "a/b/CHANGELOG.markdown", "docs/conf.py", "x.adoc"):
|
|
96
|
+
self.assertTrue(filetypes.is_doc_path(path), path)
|
|
97
|
+
for path in ("app/settings.py", "static/index.html", "docsite/app.js", "mdx/a.py", "config.yaml"):
|
|
98
|
+
self.assertFalse(filetypes.is_doc_path(path), path)
|
|
99
|
+
|
|
94
100
|
|
|
95
101
|
if __name__ == "__main__":
|
|
96
102
|
unittest.main()
|
|
@@ -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 files")
|
|
50
|
+
self.assertEqual(warn["title"], "2 secret(s) only in test 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"])
|
|
@@ -56,6 +56,15 @@ class SecretsFound(unittest.TestCase):
|
|
|
56
56
|
r = report(secrets=[self.row("h3", "tests/t.py")])
|
|
57
57
|
self.assertEqual([f["severity"] for f in findings.secrets_found(r)], ["warning"])
|
|
58
58
|
|
|
59
|
+
def test_a_value_only_in_documentation_is_a_warning_that_says_template(self):
|
|
60
|
+
r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508")])
|
|
61
|
+
f = findings.secrets_found(r)
|
|
62
|
+
self.assertEqual([x["severity"] for x in f], ["warning"])
|
|
63
|
+
self.assertEqual(f[0]["title"], "1 secret(s) only in test or documentation files")
|
|
64
|
+
self.assertIn("fixtures or templates", f[0]["advice"])
|
|
65
|
+
r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508"), self.row("h1", "app/config.py", "c2")])
|
|
66
|
+
self.assertEqual([x["severity"] for x in findings.secrets_found(r)], ["critical"], "the same value in source is a leak")
|
|
67
|
+
|
|
59
68
|
def test_placeholder_shapes_are_not_a_finding(self):
|
|
60
69
|
r = report(secrets=[self.row("h4", "web/package.json", placeholder=True)])
|
|
61
70
|
self.assertEqual(findings.secrets_found(r), [])
|
|
@@ -75,6 +84,15 @@ class PlaceholderIdentity(unittest.TestCase):
|
|
|
75
84
|
f = findings.placeholder_identity(r)
|
|
76
85
|
self.assertEqual(f[0]["severity"], "warning")
|
|
77
86
|
self.assertIn("64%", f[0]["detail"])
|
|
87
|
+
self.assertIn("Bob <bob@x.com> Your Name <you@example.com>", f[0]["advice"], "the .mailmap line, with the top real identity as the likely owner")
|
|
88
|
+
self.assertIn(".mailmap", f[0]["advice"])
|
|
89
|
+
|
|
90
|
+
def test_advice_without_a_real_identity_to_map_to(self):
|
|
91
|
+
r = report()
|
|
92
|
+
r["meta"]["identities"] = [{"name": "Your Name", "email": "you@example.com", "commits": 64}]
|
|
93
|
+
f = findings.placeholder_identity(r)
|
|
94
|
+
self.assertIn("Set user.name and user.email", f[0]["advice"])
|
|
95
|
+
self.assertNotIn("<you@example.com>", f[0]["advice"], "no real identity to suggest, so no invented line")
|
|
78
96
|
|
|
79
97
|
def test_nothing_for_real_identities(self):
|
|
80
98
|
self.assertEqual(findings.placeholder_identity(report()), [])
|
|
@@ -133,6 +151,19 @@ class SizerConcerns(unittest.TestCase):
|
|
|
133
151
|
self.assertEqual(f[0]["advice"], "Move large files to Git LFS or rewrite them out of history.")
|
|
134
152
|
self.assertEqual(f[1]["advice"], "Split the widest directory into subdirectories; a directory that wide slows every checkout and diff.")
|
|
135
153
|
|
|
154
|
+
def test_a_big_blob_that_left_the_tree_says_so(self):
|
|
155
|
+
r = report(sizer=[{"name": "Blobs: Maximum size", "value": "21.3 MiB", "concern": 2, "ref": "static/old.mp4"}],
|
|
156
|
+
size={"files": {"static/coming-soon.mp4": {"code": 0, "complexity": 0}}})
|
|
157
|
+
f = findings.sizer_concerns(r)[0]
|
|
158
|
+
self.assertIn("static/old.mp4, no longer in the tree", f["detail"])
|
|
159
|
+
self.assertIn("a history rewrite is only worth it for clone size", f["advice"])
|
|
160
|
+
r["size"]["files"]["static/old.mp4"] = {"code": 0, "complexity": 0}
|
|
161
|
+
f = findings.sizer_concerns(r)[0]
|
|
162
|
+
self.assertNotIn("no longer", f["detail"])
|
|
163
|
+
self.assertEqual(f["advice"], "Move large files to Git LFS or rewrite them out of history.")
|
|
164
|
+
r["size"] = {}
|
|
165
|
+
self.assertNotIn("no longer", findings.sizer_concerns(r)[0]["detail"], "without a tree listing nothing is claimed")
|
|
166
|
+
|
|
136
167
|
def test_advice_per_kind_of_concern(self):
|
|
137
168
|
# keyed on the loader's "section: metric" names, which are the only ones that occur
|
|
138
169
|
def advice(name, ref=""):
|
|
@@ -188,6 +219,16 @@ class TightCoupling(unittest.TestCase):
|
|
|
188
219
|
{"entity": "src/a.js", "coupled": "src/a.test.js", "degree": 100, "average-revs": 9}]
|
|
189
220
|
self.assertEqual(findings.tight_coupling(report(coupling=pairs)), [])
|
|
190
221
|
|
|
222
|
+
def test_pairs_of_files_no_longer_in_the_tree_are_history(self):
|
|
223
|
+
pairs = [{"entity": "static/financial-reporting.html", "coupled": "static/internal-audit.html", "degree": 100, "average-revs": 10},
|
|
224
|
+
{"entity": "sections/a.html", "coupled": "sections/b.html", "degree": 90, "average-revs": 8}]
|
|
225
|
+
tree = {"files": {"sections/a.html": {"code": 1, "complexity": 0}, "sections/b.html": {"code": 1, "complexity": 0}}}
|
|
226
|
+
f = findings.tight_coupling(report(coupling=pairs, size=tree))
|
|
227
|
+
self.assertIn("1 pair changes", f[0]["detail"])
|
|
228
|
+
self.assertNotIn("financial-reporting", f[0]["detail"])
|
|
229
|
+
f = findings.tight_coupling(report(coupling=pairs))
|
|
230
|
+
self.assertIn("2 pairs", f[0]["detail"], "without a tree listing every pair counts")
|
|
231
|
+
|
|
191
232
|
def test_single_pair_reads_grammatically(self):
|
|
192
233
|
pairs = [{"entity": "a", "coupled": "b", "degree": 100, "average-revs": 10}]
|
|
193
234
|
f = findings.tight_coupling(report(coupling=pairs))
|
|
@@ -45,6 +45,20 @@ class Placeholder(unittest.TestCase):
|
|
|
45
45
|
for value in [VERSION, "1.2.3", "10.4.0+build.7", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", "abcdef…"]:
|
|
46
46
|
self.assertTrue(leaks.is_placeholder(value), value)
|
|
47
47
|
|
|
48
|
+
def test_a_key_block_without_key_material_is_a_template(self):
|
|
49
|
+
# Google's service-account sample: header, a dotted body, footer; a real body is hundreds of base64 characters
|
|
50
|
+
for body in ["...", "\\n...\\n", "", "…", "xxxx"]:
|
|
51
|
+
self.assertTrue(leaks.is_placeholder(f"-----BEGIN PRIVATE KEY-----{body}-----END PRIVATE KEY-----"), body)
|
|
52
|
+
self.assertTrue(leaks.is_placeholder("-----BEGIN RSA PRIVATE KEY-----\\n...\\n-----END RSA PRIVATE KEY-----\\n"))
|
|
53
|
+
real = "-----BEGIN PRIVATE KEY-----\\n" + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC" * 4 + "\\n-----END PRIVATE KEY-----"
|
|
54
|
+
self.assertFalse(leaks.is_placeholder(real))
|
|
55
|
+
|
|
56
|
+
def test_template_markers_are_placeholders(self):
|
|
57
|
+
for value in ["your-project-id", "YOUR_API_KEY", "<your-token-here>", "xxxxxxxxxxxxxxxxxxxx", "XXXX-XXXX-XXXX", "changeme", "CHANGE_ME", "replace-me"]:
|
|
58
|
+
self.assertTrue(leaks.is_placeholder(value), value)
|
|
59
|
+
for value in ["AKIA" + "X" * 16, "6L" + "x" * 38, "ghp_" + "a1" * 18, "yourkey" + "9" * 20]:
|
|
60
|
+
self.assertFalse(leaks.is_placeholder(value), "a marker inside real-looking material is not enough: " + value)
|
|
61
|
+
|
|
48
62
|
def test_anything_else_is_taken_seriously(self):
|
|
49
63
|
# built at runtime: a literal in these shapes would trip secret scanners on this very file
|
|
50
64
|
key_id, long_key = "AKIA" + "X" * 16, "6L" + "x" * 38
|
|
@@ -136,12 +150,22 @@ class Group(unittest.TestCase):
|
|
|
136
150
|
groups = leaks.group(rows)
|
|
137
151
|
self.assertEqual([g["value"] for g in groups], ["h1", "h2", "h3"], "source first, then by places; placeholders left out")
|
|
138
152
|
self.assertEqual(groups[0], {"value": "h1", "rule": "generic-api-key", "files": ["app/settings.py"], "commits": ["c1", "c2"],
|
|
139
|
-
"places": 2, "test": False})
|
|
153
|
+
"places": 2, "test": False, "docs": False})
|
|
140
154
|
self.assertEqual(groups[1]["files"], ["tests/data/a.html", "app/tests/data/a.html"])
|
|
141
155
|
self.assertEqual(groups[1]["places"], 2)
|
|
142
156
|
self.assertTrue(groups[1]["test"])
|
|
143
157
|
self.assertEqual(leaks.placeholders(rows), 1)
|
|
144
158
|
|
|
159
|
+
def test_a_value_only_in_documentation_is_flagged_as_such(self):
|
|
160
|
+
groups = leaks.group([self.row("h1", "docs/GA4-API-INTEGRATION.md", "c1"), self.row("h1", "README.md", "c2")])
|
|
161
|
+
self.assertTrue(groups[0]["docs"])
|
|
162
|
+
self.assertFalse(groups[0]["test"])
|
|
163
|
+
groups = leaks.group([self.row("h1", "docs/setup.md", "c1"), self.row("h1", "app/a.py", "c2")])
|
|
164
|
+
self.assertFalse(groups[0]["docs"], "one place in source is enough")
|
|
165
|
+
groups = leaks.group([self.row("h1", "tests/t.py", "c1")])
|
|
166
|
+
self.assertFalse(groups[0]["docs"])
|
|
167
|
+
self.assertTrue(groups[0]["test"])
|
|
168
|
+
|
|
145
169
|
def test_a_value_seen_in_source_and_tests_counts_as_source(self):
|
|
146
170
|
groups = leaks.group([self.row("h1", "tests/t.py", "c1"), self.row("h1", "app/a.py", "c2")])
|
|
147
171
|
self.assertFalse(groups[0]["test"])
|
|
@@ -313,6 +313,18 @@ class Report(unittest.TestCase):
|
|
|
313
313
|
hot = _section_text(rendered(r, [], width=200), "\u25c6 Hotspots")
|
|
314
314
|
self.assertIn("no source hotspots; 1 test file hidden; --full shows them", hot)
|
|
315
315
|
|
|
316
|
+
def test_default_coupling_hides_pairs_of_deleted_files_and_says_so(self):
|
|
317
|
+
r = sample_report() # the tree holds static/index.html and static/apps-metadata.json only
|
|
318
|
+
r["coupling"] = [{"entity": "static/index.html", "coupled": "static/apps-metadata.json", "degree": 90, "average-revs": 11},
|
|
319
|
+
{"entity": "static/tax.html", "coupled": "static/treasury.html", "degree": 85, "average-revs": 11}]
|
|
320
|
+
coupling = _section_text(rendered(r, [], width=200), "Change coupling")
|
|
321
|
+
self.assertIn("static/index.html", coupling)
|
|
322
|
+
self.assertNotIn("static/tax.html", coupling)
|
|
323
|
+
self.assertIn("1 historical pair hidden; --full shows them", coupling)
|
|
324
|
+
full = _section_text(rendered(r, [], width=200, full=True), "Change coupling")
|
|
325
|
+
self.assertIn("static/tax.html", full)
|
|
326
|
+
self.assertNotIn("hidden", full)
|
|
327
|
+
|
|
316
328
|
def test_coupling_with_only_test_pairs_says_what_was_hidden(self):
|
|
317
329
|
r = sample_report()
|
|
318
330
|
r["coupling"] = [{"entity": "static/tax.html", "coupled": "tests/test_tax.py", "degree": 100, "average-revs": 11}]
|
|
@@ -9,10 +9,17 @@ from gitmole import blame, run
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class ClassifyTarget(unittest.TestCase):
|
|
12
|
-
def
|
|
12
|
+
def test_existing_repository_is_a_path(self):
|
|
13
13
|
with tempfile.TemporaryDirectory() as d:
|
|
14
|
+
subprocess.run(["git", "init", "-q", d], check=True)
|
|
14
15
|
self.assertEqual(run.classify_target(d), ("path", os.path.abspath(d)))
|
|
15
16
|
|
|
17
|
+
def test_directory_that_is_not_a_repository_raises(self):
|
|
18
|
+
with tempfile.TemporaryDirectory() as d:
|
|
19
|
+
with self.assertRaises(ValueError) as cm:
|
|
20
|
+
run.classify_target(d)
|
|
21
|
+
self.assertIn("not a git repository", str(cm.exception))
|
|
22
|
+
|
|
16
23
|
def test_owner_slash_repo_is_remote(self):
|
|
17
24
|
self.assertEqual(run.classify_target("acme/widgets"), ("remote", "acme/widgets"))
|
|
18
25
|
|
|
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
|