gitmole 0.6.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.
Files changed (55) hide show
  1. {gitmole-0.6.0 → gitmole-0.6.2}/PKG-INFO +6 -3
  2. {gitmole-0.6.0 → gitmole-0.6.2}/README.md +5 -2
  3. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/__init__.py +1 -1
  4. gitmole-0.6.2/gitmole/clean.py +90 -0
  5. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/cli.py +72 -21
  6. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/run.py +3 -0
  7. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole.egg-info/PKG-INFO +6 -3
  8. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole.egg-info/SOURCES.txt +2 -0
  9. gitmole-0.6.2/tests/test_clean.py +109 -0
  10. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_cli.py +179 -0
  11. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_run.py +8 -1
  12. {gitmole-0.6.0 → gitmole-0.6.2}/LICENSE +0 -0
  13. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/__main__.py +0 -0
  14. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/backtest.py +0 -0
  15. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/banner.py +0 -0
  16. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/blame.py +0 -0
  17. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/filetypes.py +0 -0
  18. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/findings.py +0 -0
  19. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/functions.py +0 -0
  20. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/hotspots.py +0 -0
  21. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/identity.py +0 -0
  22. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/knowledge.py +0 -0
  23. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/leaks.py +0 -0
  24. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/load.py +0 -0
  25. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/loss.py +0 -0
  26. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/maat.py +0 -0
  27. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/render.py +0 -0
  28. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/textfmt.py +0 -0
  29. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/trend.py +0 -0
  30. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole/watch.py +0 -0
  31. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole.egg-info/dependency_links.txt +0 -0
  32. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole.egg-info/entry_points.txt +0 -0
  33. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole.egg-info/requires.txt +0 -0
  34. {gitmole-0.6.0 → gitmole-0.6.2}/gitmole.egg-info/top_level.txt +0 -0
  35. {gitmole-0.6.0 → gitmole-0.6.2}/pyproject.toml +0 -0
  36. {gitmole-0.6.0 → gitmole-0.6.2}/setup.cfg +0 -0
  37. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_backtest.py +0 -0
  38. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_banner.py +0 -0
  39. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_blame.py +0 -0
  40. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_filetypes.py +0 -0
  41. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_findings.py +0 -0
  42. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_functions.py +0 -0
  43. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_golden.py +0 -0
  44. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_hotspots.py +0 -0
  45. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_identity.py +0 -0
  46. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_knowledge.py +0 -0
  47. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_leaks.py +0 -0
  48. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_load.py +0 -0
  49. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_loss.py +0 -0
  50. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_maat.py +0 -0
  51. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_packaging.py +0 -0
  52. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_render.py +0 -0
  53. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_textfmt.py +0 -0
  54. {gitmole-0.6.0 → gitmole-0.6.2}/tests/test_trend.py +0 -0
  55. {gitmole-0.6.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.6.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. Local clones are
148
- only read, but the log export and the secrets scan touch all branches.
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. Local clones are
128
- only read, but the log export and the secrets scan touch all branches.
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
@@ -1,3 +1,3 @@
1
1
  """gitmole: offline git repository analysis with a terminal report."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.6.2"
@@ -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 = "--risk-threshold needs --risk" if args.risk_threshold is not None and not args.risk else None
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
- for i, name in enumerate(repos, 1):
329
- ui.print(f"[bold]{name}[/bold] [dim]({i}/{len(repos)})[/dim]")
330
- try:
331
- repo_dir = cloner(f"{owner}/{name}", parent)
332
- except run.GhError as e:
333
- ui.print(f"[red]could not clone {owner}/{name}:[/red] {e}", soft_wrap=True)
334
- continue
335
- out_dir = os.path.join(base, name)
336
- try:
337
- _analyse(repo_dir, out_dir, args, ui, planner, estimator)
338
- except Interrupted:
339
- return 130
340
- except NoCommits as e:
341
- ui.print(f"[yellow]{name}:[/yellow] {e}; skipped")
342
- continue
343
- report = load.load_report(out_dir)
344
- reports.append((name, report, findings.evaluate(report)))
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)
@@ -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.6.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. Local clones are
148
- only read, but the log export and the secrets scan touch all branches.
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
@@ -9,10 +9,17 @@ from gitmole import blame, run
9
9
 
10
10
 
11
11
  class ClassifyTarget(unittest.TestCase):
12
- def test_existing_directory_is_a_path(self):
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
File without changes
File without changes
File without changes
File without changes