gitmole 0.6.2__tar.gz → 0.6.4__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 (57) hide show
  1. {gitmole-0.6.2 → gitmole-0.6.4}/PKG-INFO +1 -1
  2. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/__init__.py +1 -1
  3. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/cli.py +13 -4
  4. gitmole-0.6.4/gitmole/coupling.py +55 -0
  5. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/filetypes.py +18 -0
  6. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/findings.py +26 -12
  7. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/render.py +50 -7
  8. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole.egg-info/PKG-INFO +1 -1
  9. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole.egg-info/SOURCES.txt +2 -0
  10. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_cli.py +30 -1
  11. gitmole-0.6.4/tests/test_coupling.py +52 -0
  12. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_filetypes.py +14 -0
  13. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_findings.py +48 -2
  14. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_render.py +49 -2
  15. {gitmole-0.6.2 → gitmole-0.6.4}/LICENSE +0 -0
  16. {gitmole-0.6.2 → gitmole-0.6.4}/README.md +0 -0
  17. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/__main__.py +0 -0
  18. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/backtest.py +0 -0
  19. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/banner.py +0 -0
  20. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/blame.py +0 -0
  21. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/clean.py +0 -0
  22. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/functions.py +0 -0
  23. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/hotspots.py +0 -0
  24. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/identity.py +0 -0
  25. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/knowledge.py +0 -0
  26. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/leaks.py +0 -0
  27. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/load.py +0 -0
  28. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/loss.py +0 -0
  29. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/maat.py +0 -0
  30. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/run.py +0 -0
  31. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/textfmt.py +0 -0
  32. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/trend.py +0 -0
  33. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole/watch.py +0 -0
  34. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole.egg-info/dependency_links.txt +0 -0
  35. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole.egg-info/entry_points.txt +0 -0
  36. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole.egg-info/requires.txt +0 -0
  37. {gitmole-0.6.2 → gitmole-0.6.4}/gitmole.egg-info/top_level.txt +0 -0
  38. {gitmole-0.6.2 → gitmole-0.6.4}/pyproject.toml +0 -0
  39. {gitmole-0.6.2 → gitmole-0.6.4}/setup.cfg +0 -0
  40. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_backtest.py +0 -0
  41. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_banner.py +0 -0
  42. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_blame.py +0 -0
  43. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_clean.py +0 -0
  44. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_functions.py +0 -0
  45. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_golden.py +0 -0
  46. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_hotspots.py +0 -0
  47. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_identity.py +0 -0
  48. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_knowledge.py +0 -0
  49. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_leaks.py +0 -0
  50. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_load.py +0 -0
  51. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_loss.py +0 -0
  52. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_maat.py +0 -0
  53. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_packaging.py +0 -0
  54. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_run.py +0 -0
  55. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_textfmt.py +0 -0
  56. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_trend.py +0 -0
  57. {gitmole-0.6.2 → gitmole-0.6.4}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.6.2
3
+ Version: 0.6.4
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
@@ -1,3 +1,3 @@
1
1
  """gitmole: offline git repository analysis with a terminal report."""
2
2
 
3
- __version__ = "0.6.2"
3
+ __version__ = "0.6.4"
@@ -179,14 +179,23 @@ def _clean(args, console: Console, ask) -> int:
179
179
  """Handle --clean: list what gitmole left behind, ask once, remove. ask(prompt) returns the answer."""
180
180
  from . import clean, render
181
181
 
182
- found = clean.find(args.target or ".", clean.temp_dir())
182
+ tmp = clean.temp_dir()
183
+ found = clean.find(args.target or ".", tmp)
183
184
  if not found:
184
185
  console.print("nothing to clean")
185
186
  return 0
186
187
  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")
188
+ columns = [("directory", {"no_wrap": True}), ("size", render.RIGHT), ("modified", {})]
189
+ clones = [r for r in found if os.path.dirname(r[0]) == tmp]
190
+ listed = found
191
+ if clones and not args.full:
192
+ # one row for the temp folder: the clones differ only in their random suffix; --full lists them all
193
+ label = f"{clean.TEMP_PREFIX}* ({len(clones)} temp clone{'s' if len(clones) > 1 else ''})"
194
+ listed = [(os.path.join(tmp, label), sum(s for _, s, _ in clones), max(m for _, _, m in clones))]
195
+ listed += [r for r in found if os.path.dirname(r[0]) != tmp]
196
+ rows = [(path, clean.human(size), time.strftime("%Y-%m-%d", time.localtime(mtime))) for path, size, mtime in listed]
197
+ rows = render._shorten(rows, console.width, columns) # middle-elided paths, one line per row; the last segment stays whole
198
+ sec = render._section("Left behind", columns, rows, caption=f"{_dirs(len(found))}, {clean.human(total)} in all")
190
199
  render.print_section(console, sec)
191
200
  console.print(Text(""))
192
201
  if not args.yes:
@@ -0,0 +1,55 @@
1
+ """Change-coupling pairs grouped into clusters: a directory whose files all change together (generated
2
+ tables, one-per-version data files) is one fact, not a page of pairs."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections import defaultdict
7
+
8
+ ROOT = "(root files)"
9
+
10
+
11
+ def _dir(path: str) -> str:
12
+ head = os.path.dirname(path)
13
+ return head + "/" if head else ROOT
14
+
15
+
16
+ def _components(pairs: list) -> list:
17
+ """Connected groups of files among `pairs` (union-find), each as the list of its pairs."""
18
+ parent = {}
19
+
20
+ def find(x):
21
+ parent.setdefault(x, x)
22
+ while parent[x] != x:
23
+ parent[x] = parent[parent[x]]
24
+ x = parent[x]
25
+ return x
26
+
27
+ for p in pairs:
28
+ parent[find(p["entity"])] = find(p["coupled"])
29
+ groups = defaultdict(list)
30
+ for p in pairs:
31
+ groups[find(p["entity"])].append(p)
32
+ return list(groups.values())
33
+
34
+
35
+ def clusters(pairs: list, min_files: int = 4) -> tuple:
36
+ """Split `pairs` into (groups, rest). Pairs whose two files share a directory are gathered per
37
+ directory and then into connected groups; a group touching at least `min_files` distinct files
38
+ becomes one cluster with the file and pair counts, the weakest degree and the mean of the pairs'
39
+ average revisions. Every other pair comes back unchanged, in its original order. Two unrelated
40
+ pairs in one directory are not a cluster. Clusters are largest first."""
41
+ by_dir = defaultdict(list)
42
+ for p in pairs:
43
+ if _dir(p["entity"]) == _dir(p["coupled"]):
44
+ by_dir[_dir(p["entity"])].append(p)
45
+ groups, taken = [], set()
46
+ for directory, ps in by_dir.items():
47
+ for component in _components(ps):
48
+ files = {p["entity"] for p in component} | {p["coupled"] for p in component}
49
+ if len(files) < min_files:
50
+ continue
51
+ groups.append({"dir": directory, "files": len(files), "pairs": len(component), "degree": min(p["degree"] for p in component),
52
+ "average-revs": round(sum(p["average-revs"] for p in component) / len(component))})
53
+ taken.update(id(p) for p in component)
54
+ groups.sort(key=lambda g: (-g["files"], -g["degree"], g["dir"]))
55
+ return groups, [p for p in pairs if id(p) not in taken]
@@ -65,6 +65,24 @@ def is_doc_path(path: str) -> bool:
65
65
  return bool(_DOC_PATH.search(path))
66
66
 
67
67
 
68
+ _SAMPLE_PATH = re.compile(r"(^|/)(examples?|samples?|fixtures?|testdata|demos?|rules)(/|$)", re.I)
69
+
70
+
71
+ def is_sample_path(path: str) -> bool:
72
+ """Example, sample, fixture, demo and rule directories: a value there is a specimen (a language
73
+ sample, a scanner's own rule definitions), not a credential in use."""
74
+ return bool(_SAMPLE_PATH.search(path))
75
+
76
+
77
+ _VENDOR_PATH = re.compile(r"(^|/)(_?vendor|node_modules|third_?party|external)(/|$)", re.I)
78
+
79
+
80
+ def is_vendor_path(path: str) -> bool:
81
+ """Vendored and third-party trees: somebody else's code, so its complexity and its single
82
+ importer are not this repository's risk."""
83
+ return bool(_VENDOR_PATH.search(path))
84
+
85
+
68
86
  def key(path: str) -> str:
69
87
  """The lowercased extension, or the whole lowercased name when there is none."""
70
88
  name = path.rsplit("/", 1)[-1].lower()
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import re
5
5
 
6
- from . import filetypes, hotspots, knowledge, leaks, loss, textfmt, trend
6
+ from . import coupling, filetypes, hotspots, knowledge, leaks, loss, textfmt, trend
7
7
 
8
8
  SEVERITIES = ["critical", "warning", "info"]
9
9
 
@@ -40,13 +40,14 @@ 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) 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."""
43
+ test files (fixtures, saved pages), example or rule directories (language samples, a scanner's own
44
+ rules) or documentation (templates) is a warning, so a critical gate does not trip on test data or a
45
+ planning document. Version strings, template markers and key blocks without key material were
46
+ flagged as placeholders and are not a finding."""
46
47
  groups = leaks.group(report.get("secrets") or [])
47
48
 
48
49
  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
+ return any(not (filetypes.is_test_path(f) or filetypes.is_doc_path(f) or filetypes.is_sample_path(f)) for f in g["files"])
50
51
  source = [g for g in groups if in_source(g)]
51
52
  aside = [g for g in groups if not in_source(g)]
52
53
  ignore = "Add the fingerprint of any false positive from secrets.json to .betterleaksignore in the repository."
@@ -55,7 +56,7 @@ def secrets_found(report: dict) -> list:
55
56
  out.append(_f("critical", f"{len(source)} secret(s) in history", _secret_statement(source),
56
57
  f"Rotate them; deleting the file does not remove them from git. {ignore}"))
57
58
  if aside:
58
- out.append(_f("warning", f"{len(aside)} secret(s) only in test or documentation files", _secret_statement(aside),
59
+ out.append(_f("warning", f"{len(aside)} secret(s) only in test, example or documentation files", _secret_statement(aside),
59
60
  f"Confirm they are fixtures or templates, not live keys. {ignore}"))
60
61
  return out
61
62
 
@@ -93,9 +94,10 @@ def placeholder_identity(report: dict, min_share: float = 0.01) -> list:
93
94
 
94
95
 
95
96
  def _source_ownership(report: dict) -> list:
96
- """Ownership rows for source files. Test files are left out of every rule that names a next
97
- step: owning the tests is not the knowledge risk. The default tables leave them out too."""
98
- return [r for r in report.get("ownership") or [] if not filetypes.is_test_path(r["entity"])]
97
+ """Ownership rows for source files. Test files and vendored trees are left out of every rule that
98
+ names a next step: owning the tests is not the knowledge risk, and whoever imported vendor/ did
99
+ not write it. The default tables leave test files out too."""
100
+ return [r for r in report.get("ownership") or [] if not (filetypes.is_test_path(r["entity"]) or filetypes.is_vendor_path(r["entity"]))]
99
101
 
100
102
 
101
103
  def bus_factor(report: dict, threshold: float = 0.7, min_lines: int = 200) -> list:
@@ -183,11 +185,21 @@ def tight_coupling(report: dict, min_degree: int = 80, min_revs: int = 5) -> lis
183
185
  if not pairs:
184
186
  return []
185
187
  pairs.sort(key=lambda p: (-p["degree"], -p["average-revs"]))
188
+ groups, pairs = coupling.clusters(pairs)
189
+ when = f"together at least {min_degree}% of the time"
186
190
  top = "; ".join(f"{p['entity']} + {p['coupled']} ({p['degree']}%)" for p in pairs[:3])
191
+ if groups:
192
+ # a directory of files that change as one is a generator or a shared layout, said once
193
+ named = ", ".join(f"{g['files']} files in {g['dir']}" for g in groups[:2]) + (f" and {len(groups) - 2} more directories" if len(groups) > 2 else "")
194
+ rest = (f", and {_plural(len(pairs), 'more pair')} {'does' if len(pairs) == 1 else 'do'}: {top}." if pairs
195
+ else f", {_plural(sum(g['pairs'] for g in groups), 'pair')} in all.")
196
+ first = groups[0]
197
+ return [_f("info", "Files that always change together", f"{named} change {when}{rest}",
198
+ f"Review {first['dir']} first: {first['files']} files change as one; a generator or a shared layout links them.")]
187
199
  count = f"{len(pairs)} pair changes" if len(pairs) == 1 else f"{len(pairs)} pairs change"
188
200
  first = pairs[0]
189
201
  return [_f("info", "Files that always change together",
190
- f"{count} together at least {min_degree}% of the time, e.g. {top}.",
202
+ f"{count} {when}, e.g. {top}.",
191
203
  f"Review {first['entity']} and {first['coupled']} first: a shared layout or a hidden dependency links them.")]
192
204
 
193
205
 
@@ -363,8 +375,10 @@ def _partial_functions(report: dict) -> str:
363
375
 
364
376
 
365
377
  def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list:
366
- """Functions that are both long and complex, in source files. A warning when one sits in a hotspot."""
367
- big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines and not filetypes.is_test_path(f["file"])]
378
+ """Functions that are both long and complex, in this repository's own source files: test files and
379
+ vendored code are left out. A warning when one sits in a hotspot."""
380
+ big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines
381
+ and not (filetypes.is_test_path(f["file"]) or filetypes.is_vendor_path(f["file"]))]
368
382
  if not big:
369
383
  return []
370
384
  big.sort(key=lambda f: (-f["ccn"], -f["nloc"], f["file"], f["function"], f["start"]))
@@ -13,7 +13,7 @@ from rich.panel import Panel
13
13
  from rich.table import Table
14
14
  from rich.text import Text
15
15
 
16
- from . import filetypes, hotspots, identity, knowledge, leaks, loss, textfmt, trend, watch
16
+ from . import coupling, filetypes, hotspots, identity, knowledge, leaks, loss, textfmt, trend, watch
17
17
 
18
18
  SEVERITY_STYLE = {"critical": "bold red", "warning": "yellow", "info": "cyan"}
19
19
 
@@ -78,8 +78,11 @@ def _more(total: int, limit) -> str:
78
78
  return f"and {total - limit} more" if limit is not None and total > limit else None
79
79
 
80
80
 
81
- def _hide_tests(rows: list, path_of, full, noun="test file", plural=None) -> tuple:
82
- """Drop rows whose path (or any of whose paths) is a test path, unless `full` is True.
81
+ HIDDEN_SUFFIX = "; --full shows them"
82
+
83
+
84
+ def _hide_rows(rows: list, path_of, full, pred, noun: str, plural=None) -> tuple:
85
+ """Drop rows whose path (or any of whose paths) satisfies `pred`, unless `full` is True.
83
86
  `path_of(row)` returns a single path or a tuple of paths to check. `noun` names one hidden row
84
87
  and `plural` names several, `noun + "s"` by default (a multi-word noun gives its own plural:
85
88
  "function in a test file" -> "functions in test files"). Returns (rows, note), `note` being
@@ -90,14 +93,41 @@ def _hide_tests(rows: list, path_of, full, noun="test file", plural=None) -> tup
90
93
  for row in rows:
91
94
  paths = path_of(row)
92
95
  paths = (paths,) if isinstance(paths, str) else paths
93
- if any(filetypes.is_test_path(p) for p in paths):
96
+ if any(pred(p) for p in paths):
94
97
  hidden += 1
95
98
  else:
96
99
  kept.append(row)
97
- note = f"{hidden} {noun if hidden == 1 else plural or noun + 's'} hidden; --full shows them" if hidden else None
100
+ note = f"{hidden} {noun if hidden == 1 else plural or noun + 's'} hidden{HIDDEN_SUFFIX}" if hidden else None
98
101
  return kept, note
99
102
 
100
103
 
104
+ def _hide_tests(rows: list, path_of, full, noun="test file", plural=None) -> tuple:
105
+ """Test files: they change with every fix, so they are not a signal on their own."""
106
+ return _hide_rows(rows, path_of, full, filetypes.is_test_path, noun, plural)
107
+
108
+
109
+ def _hide_vendor(rows: list, path_of, full, noun="file in vendored code", plural="files in vendored code") -> tuple:
110
+ """Vendored trees: somebody else's code, not this repository's risk."""
111
+ return _hide_rows(rows, path_of, full, filetypes.is_vendor_path, noun, plural)
112
+
113
+
114
+ def _join_hidden(*notes) -> str:
115
+ """Several hidden-row notes as one caption phrase: 'A hidden; B hidden; --full shows them'."""
116
+ parts = [n[:-len(HIDDEN_SUFFIX)] if n.endswith(HIDDEN_SUFFIX) else n for n in notes if n]
117
+ return "; ".join(parts) + HIDDEN_SUFFIX if parts else None
118
+
119
+
120
+ def _hide_deleted(rows: list, report: dict, full) -> tuple:
121
+ """Drop hotspot rows for files no longer in the tree, unless `full` is True or there is no tree
122
+ listing to judge by: a deleted file's churn is history. Returns (rows, note) like _hide_tests."""
123
+ tree = (report.get("size") or {}).get("files") or {}
124
+ if full is True or not tree:
125
+ return rows, None
126
+ kept = [h for h in rows if h["code"] is not None]
127
+ hidden = len(rows) - len(kept)
128
+ return kept, (f"{hidden} deleted file{'s' if hidden != 1 else ''} hidden{HIDDEN_SUFFIX}" if hidden else None)
129
+
130
+
101
131
  def _hide_gone(pairs: list, report: dict, full) -> tuple:
102
132
  """Drop coupled pairs where either file is no longer in the tree, unless `full` is True: they
103
133
  describe a layout that no longer exists. Returns (pairs, note) like _hide_tests."""
@@ -345,6 +375,8 @@ def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
345
375
  fixes = {f["entity"]: f["n-fixes"] for f in report.get("fixes") or []}
346
376
  scored = hotspots.ranked(report)
347
377
  scored, hidden_note = _hide_tests(scored, lambda h: h["entity"], full)
378
+ scored, deleted_note = _hide_deleted(scored, report, full)
379
+ hidden_note = _join_hidden(hidden_note, deleted_note)
348
380
  title = "Hotspots (score = revisions × lines of code)" if full is True else "Hotspots"
349
381
  limit = _limit("Hotspots", full)
350
382
  series = (report.get("trend") or {}).get("files") or {}
@@ -376,9 +408,18 @@ def coupling_section(report: dict, full: bool = True, width=None) -> dict:
376
408
  pairs = sorted((p for p in report.get("coupling") or [] if p["average-revs"] >= 5), key=lambda p: (-p["degree"], -p["average-revs"]))
377
409
  pairs, hidden_note = _hide_tests(pairs, lambda p: (p["entity"], p["coupled"]), full, noun="test pair")
378
410
  pairs, gone_note = _hide_gone(pairs, report, full)
379
- hidden_note = "; ".join(n for n in (hidden_note, gone_note) if n) or None
411
+ groups, cluster_note = [], None
412
+ if full is not True:
413
+ # a directory whose files all change together is one row; --full lists every pair
414
+ groups, pairs = coupling.clusters(pairs)
415
+ if groups:
416
+ n_pairs, n_dirs = sum(g["pairs"] for g in groups), len(groups)
417
+ cluster_note = (f"{n_pairs} pairs in {n_dirs} director{'y' if n_dirs == 1 else 'ies'} shown as "
418
+ f"{'one row' if n_dirs == 1 else 'one row each'}{HIDDEN_SUFFIX}")
419
+ hidden_note = _join_hidden(hidden_note, gone_note, cluster_note)
380
420
  limit = _limit("Change coupling", full)
381
- rows = [(p["entity"], p["coupled"], f"{p['degree']}%", p["average-revs"]) for p in pairs[:limit]]
421
+ rows = [(f"{g['dir']} ({g['files']} files)", "each other", f"{g['degree']}%", g["average-revs"]) for g in groups]
422
+ rows += [(p["entity"], p["coupled"], f"{p['degree']}%", p["average-revs"]) for p in pairs[:max(limit - len(groups), 0) if limit else None]]
382
423
  columns = [("file", PATH), ("changes with", PATH), ("degree", RIGHT), ("avg revs", RIGHT)]
383
424
  if full is not True:
384
425
  columns, rows = _keep(columns, rows, ["file", "changes with", "degree"])
@@ -432,6 +473,8 @@ def functions_section(report: dict, full: bool = True, width=None) -> dict:
432
473
  measured = report.get("functions") or []
433
474
  funcs = sorted((f for f in measured if f["ccn"] >= CCN_FLOOR), key=lambda f: (-f["ccn"], -f["nloc"], f["file"], f["function"], f["start"]))
434
475
  funcs, hidden_note = _hide_tests(funcs, lambda f: f["file"], full, noun="function in a test file", plural="functions in test files")
476
+ funcs, vendor_note = _hide_vendor(funcs, lambda f: f["file"], full, noun="function in vendored code", plural="functions in vendored code")
477
+ hidden_note = _join_hidden(hidden_note, vendor_note)
435
478
  limit = _limit("Complex functions", full)
436
479
  rows = [(f["function"], f["file"], f["ccn"], f["nloc"], f["params"]) for f in funcs[:limit]]
437
480
  columns = [("function", {"overflow": "fold"}), ("file", PATH), ("ccn", RIGHT), ("lines", RIGHT), ("params", RIGHT)]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.6.2
3
+ Version: 0.6.4
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
@@ -8,6 +8,7 @@ gitmole/banner.py
8
8
  gitmole/blame.py
9
9
  gitmole/clean.py
10
10
  gitmole/cli.py
11
+ gitmole/coupling.py
11
12
  gitmole/filetypes.py
12
13
  gitmole/findings.py
13
14
  gitmole/functions.py
@@ -34,6 +35,7 @@ tests/test_banner.py
34
35
  tests/test_blame.py
35
36
  tests/test_clean.py
36
37
  tests/test_cli.py
38
+ tests/test_coupling.py
37
39
  tests/test_filetypes.py
38
40
  tests/test_findings.py
39
41
  tests/test_functions.py
@@ -634,13 +634,42 @@ class Clean(unittest.TestCase):
634
634
  self.assertEqual(rc, 0)
635
635
  self.assertIn("Left behind", text)
636
636
  self.assertIn("analysis-a", text)
637
- self.assertIn("gitmole-x", text)
637
+ self.assertIn("gitmole-* (1 temp clone)", text, "temp clones collapse to one row per temp folder")
638
+ self.assertNotIn("gitmole-x", text)
638
639
  self.assertEqual(len(asked), 1)
639
640
  self.assertIn("Delete 2 directories", asked[0])
640
641
  self.assertIn("kept", text)
641
642
  self.assertTrue(out_kept)
642
643
  self.assertTrue(tmp_kept)
643
644
 
645
+ def test_full_lists_every_temp_clone(self):
646
+ def go(work, tmp):
647
+ os.makedirs(os.path.join(tmp, "gitmole-x"))
648
+ os.makedirs(os.path.join(tmp, "gitmole-y"))
649
+ c = Console(file=io.StringIO(), width=120, record=True, force_terminal=True, color_system=None)
650
+ rc = cli.main(["--clean", work, "--full"], console=c, ask=lambda q: "n")
651
+ return rc, c.export_text()
652
+ rc, text = self._with_tmp(go)
653
+ self.assertEqual(rc, 0)
654
+ self.assertIn("gitmole-x", text)
655
+ self.assertIn("gitmole-y", text)
656
+ self.assertNotIn("temp clones", text)
657
+
658
+ def test_rows_stay_on_one_line_at_a_narrow_width(self):
659
+ def go(work, tmp):
660
+ deep = os.path.join(work, "some", "rather", "long", "chain", "of", "directories")
661
+ self._output(os.path.join(deep, "analysis-widgets"))
662
+ os.makedirs(os.path.join(tmp, "gitmole-x"))
663
+ c = Console(file=io.StringIO(), width=60, record=True, force_terminal=True, color_system=None)
664
+ rc = cli.main(["--clean", deep], console=c, ask=lambda q: "n")
665
+ return rc, c.export_text()
666
+ rc, text = self._with_tmp(go)
667
+ self.assertEqual(rc, 0)
668
+ rows = [line for line in text.splitlines() if line.strip().startswith("/")]
669
+ self.assertEqual(len(rows), 2, text)
670
+ self.assertTrue(any("analysis-widgets" in r and "…/" in r for r in rows), text)
671
+ self.assertTrue(all(" B " in r and "2026-" in r for r in rows), "each row carries its size and date on the same line:\n" + text)
672
+
644
673
  def test_yes_answer_removes_and_reports(self):
645
674
  def go(work, tmp):
646
675
  self._output(os.path.join(work, "analysis-a"))
@@ -0,0 +1,52 @@
1
+ import unittest
2
+
3
+ from gitmole import coupling
4
+
5
+
6
+ def pair(a, b, degree=100, revs=10):
7
+ return {"entity": a, "coupled": b, "degree": degree, "average-revs": revs}
8
+
9
+
10
+ def clique(directory, names, degree=100, revs=10):
11
+ files = [f"{directory}/{n}" if directory else n for n in names]
12
+ return [pair(a, b, degree, revs) for i, a in enumerate(files) for b in files[i + 1:]]
13
+
14
+
15
+ class Clusters(unittest.TestCase):
16
+ def test_a_directory_of_four_or_more_files_becomes_one_cluster(self):
17
+ pairs = clique("rich/_unicode_data", ["u10.py", "u11.py", "u12.py", "u13.py"]) + [pair("a.py", "b.py", 90)]
18
+ groups, rest = coupling.clusters(pairs)
19
+ self.assertEqual(groups, [{"dir": "rich/_unicode_data/", "files": 4, "pairs": 6, "degree": 100, "average-revs": 10}])
20
+ self.assertEqual(rest, [pair("a.py", "b.py", 90)])
21
+
22
+ def test_the_cluster_degree_is_the_weakest_pair(self):
23
+ pairs = clique("d", ["a", "b", "c", "e"], degree=100)
24
+ pairs[2]["degree"] = 83
25
+ pairs[4]["average-revs"] = 40
26
+ [g], _ = coupling.clusters(pairs)
27
+ self.assertEqual(g["degree"], 83)
28
+ self.assertEqual(g["average-revs"], 15) # the mean of the pairs' averages, rounded
29
+
30
+ def test_three_files_stay_as_pairs(self):
31
+ pairs = clique("d", ["a", "b", "c"])
32
+ groups, rest = coupling.clusters(pairs)
33
+ self.assertEqual(groups, [])
34
+ self.assertEqual(rest, pairs)
35
+
36
+ def test_pairs_across_directories_never_cluster(self):
37
+ pairs = [pair("x/a", "y/b"), pair("x/c", "y/d"), pair("x/e", "y/f"), pair("x/g", "y/h")]
38
+ groups, rest = coupling.clusters(pairs)
39
+ self.assertEqual(groups, [])
40
+ self.assertEqual(len(rest), 4)
41
+
42
+ def test_root_files_cluster_under_their_own_label_and_largest_cluster_first(self):
43
+ pairs = clique("", ["a", "b", "c", "d"]) + clique("lib", ["p", "q", "r", "s", "t"], degree=90)
44
+ groups, rest = coupling.clusters(pairs)
45
+ self.assertEqual([g["dir"] for g in groups], ["lib/", "(root files)"])
46
+ self.assertEqual(rest, [])
47
+
48
+ def test_a_partial_clique_counts_the_files_it_touches(self):
49
+ pairs = [pair("d/a", "d/b"), pair("d/b", "d/c"), pair("d/c", "d/e")] # 4 files, 3 pairs, not every pair present
50
+ [g], rest = coupling.clusters(pairs)
51
+ self.assertEqual((g["files"], g["pairs"]), (4, 3))
52
+ self.assertEqual(rest, [])
@@ -97,6 +97,20 @@ class TestPaths(unittest.TestCase):
97
97
  for path in ("app/settings.py", "static/index.html", "docsite/app.js", "mdx/a.py", "config.yaml"):
98
98
  self.assertFalse(filetypes.is_doc_path(path), path)
99
99
 
100
+ def test_example_fixture_and_rule_directories(self):
101
+ for path in ("examples/language/bru.bru", "example/app.py", "samples/x.json", "sample/x.json", "fixtures/keys.pem",
102
+ "src/fixture/a.txt", "pkg/testdata/creds.yaml", "demo/x.py", "demos/x.py", "config/generate/rules/slack.go"):
103
+ self.assertTrue(filetypes.is_sample_path(path), path)
104
+ for path in ("app/settings.py", "examplesite/app.py", "src/rulesets/a.go", "sampler/x.py", "config/betterleaks.toml"):
105
+ self.assertFalse(filetypes.is_sample_path(path), path)
106
+
107
+ def test_vendored_trees(self):
108
+ for path in ("vendor/github.com/x/y.go", "web/node_modules/a/index.js", "third_party/z/a.c", "thirdparty/a.c", "_vendor/a.py",
109
+ "external/lib/a.cpp"):
110
+ self.assertTrue(filetypes.is_vendor_path(path), path)
111
+ for path in ("vendors.py", "src/vendoring/a.py", "node/a.js", "externals.txt", "app/main.go"):
112
+ self.assertFalse(filetypes.is_vendor_path(path), path)
113
+
100
114
 
101
115
  if __name__ == "__main__":
102
116
  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 or documentation files")
50
+ self.assertEqual(warn["title"], "2 secret(s) only in test, example 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,11 +56,21 @@ 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_an_example_fixture_or_rules_directory_is_a_warning(self):
60
+ r = report(secrets=[self.row("h1", "examples/language/bru.bru", "57d82e9", rule="generic-password"),
61
+ self.row("h2", "config/generate/rules/slack.go", "04bdee4", rule="slack-bot-token"),
62
+ self.row("h3", "pkg/testdata/creds.yaml", "c3")])
63
+ f = findings.secrets_found(r)
64
+ self.assertEqual([x["severity"] for x in f], ["warning"])
65
+ self.assertEqual(f[0]["title"], "3 secret(s) only in test, example or documentation files")
66
+ r = report(secrets=[self.row("h1", "examples/app.py", "c1"), self.row("h1", "app/config.py", "c2")])
67
+ self.assertEqual([x["severity"] for x in findings.secrets_found(r)], ["critical"], "the same value in source is a leak")
68
+
59
69
  def test_a_value_only_in_documentation_is_a_warning_that_says_template(self):
60
70
  r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508")])
61
71
  f = findings.secrets_found(r)
62
72
  self.assertEqual([x["severity"] for x in f], ["warning"])
63
- self.assertEqual(f[0]["title"], "1 secret(s) only in test or documentation files")
73
+ self.assertEqual(f[0]["title"], "1 secret(s) only in test, example or documentation files")
64
74
  self.assertIn("fixtures or templates", f[0]["advice"])
65
75
  r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508"), self.row("h1", "app/config.py", "c2")])
66
76
  self.assertEqual([x["severity"] for x in findings.secrets_found(r)], ["critical"], "the same value in source is a leak")
@@ -135,6 +145,13 @@ class BusFactor(unittest.TestCase):
135
145
  self.assertEqual(findings.bus_factor(r)[0]["advice"], "Pair someone with Ann on core/ first; it is 95% theirs since 2025-01-01.",
136
146
  "ownership is windowed while the headline share is not")
137
147
 
148
+ def test_vendored_trees_are_not_named_in_the_advice(self):
149
+ own = [{"entity": "vendor/github.com/x/a.go", "author": "Ann", "added": 500000, "deleted": 0},
150
+ {"entity": "core/a.py", "author": "Ann", "added": 900, "deleted": 0},
151
+ {"entity": "core/b.py", "author": "Bob", "added": 50, "deleted": 0}]
152
+ f = findings.bus_factor(report(theseus_authors={"Ann": 79, "Bob": 21}, ownership=own))
153
+ self.assertEqual(f[0]["advice"], "Pair someone with Ann on core/ first; it is 95% theirs.")
154
+
138
155
  def test_nothing_when_spread(self):
139
156
  self.assertEqual(findings.bus_factor(report()), [])
140
157
 
@@ -234,6 +251,17 @@ class TightCoupling(unittest.TestCase):
234
251
  f = findings.tight_coupling(report(coupling=pairs))
235
252
  self.assertIn("1 pair changes together", f[0]["detail"])
236
253
 
254
+ def test_a_directory_of_files_that_change_as_one_is_one_cluster(self):
255
+ files = [f"rich/_unicode_data/unicode{n}.py" for n in ("10", "11", "12", "13")]
256
+ pairs = [{"entity": a, "coupled": b, "degree": 100, "average-revs": 5} for i, a in enumerate(files) for b in files[i + 1:]]
257
+ pairs.append({"entity": "rich/a.py", "coupled": "rich/b.py", "degree": 90, "average-revs": 8})
258
+ f = findings.tight_coupling(report(coupling=pairs))
259
+ self.assertIn("4 files in rich/_unicode_data/ change together at least 80% of the time, and 1 more pair does: rich/a.py + rich/b.py (90%).", f[0]["detail"])
260
+ self.assertNotIn("unicode10", f[0]["detail"])
261
+ self.assertTrue(f[0]["detail"].endswith("Review rich/_unicode_data/ first: 4 files change as one; a generator or a shared layout links them."), f[0]["detail"])
262
+ f = findings.tight_coupling(report(coupling=pairs[:-1]))
263
+ self.assertIn("4 files in rich/_unicode_data/ change together at least 80% of the time, 6 pairs in all.", f[0]["detail"])
264
+
237
265
  def test_nothing_when_no_tight_pairs(self):
238
266
  self.assertEqual(findings.tight_coupling(report()), [])
239
267
 
@@ -338,6 +366,14 @@ class BrainMethods(unittest.TestCase):
338
366
  self.assertNotIn("test_all", f[0]["detail"])
339
367
  self.assertEqual(findings.brain_methods(report(functions=fns[:1])), [])
340
368
 
369
+ def test_vendored_functions_are_not_brain_methods(self):
370
+ fns = [{"file": "vendor/github.com/google/jsonschema-go/jsonschema/validate.go", "function": "validate", "ccn": 179, "nloc": 424, "params": 3, "start": 1, "end": 424},
371
+ {"file": "processor/workers.go", "function": "countLoopGeneric", "ccn": 56, "nloc": 164, "params": 8, "start": 1, "end": 164}]
372
+ f = findings.brain_methods(report(functions=fns))
373
+ self.assertEqual(f[0]["advice"], "Split countLoopGeneric in processor/workers.go first, before the next change lands there.")
374
+ self.assertNotIn("vendor/", f[0]["detail"])
375
+ self.assertEqual(findings.brain_methods(report(functions=fns[:1])), [])
376
+
341
377
  def test_a_partial_run_says_there_may_be_more(self):
342
378
  r = report(functions=self.FUNCS)
343
379
  self.assertNotIn("part way", findings.brain_methods(r)[0]["detail"])
@@ -402,6 +438,16 @@ class KnowledgeIslands(unittest.TestCase):
402
438
  self.assertEqual(f[0]["advice"], "Pair someone with Bob on core/ first; it is the largest at 300 lines.")
403
439
  self.assertNotIn("tests/", f[0]["detail"])
404
440
 
441
+ def test_vendored_trees_are_not_islands(self):
442
+ own = [{"entity": "vendor/github.com/x/a.go", "author": "Ann", "added": 500000, "deleted": 0},
443
+ {"entity": "web/node_modules/y/b.js", "author": "Ann", "added": 90000, "deleted": 0},
444
+ {"entity": "core/a.py", "author": "Bob", "added": 300, "deleted": 0}]
445
+ f = findings.knowledge_islands(report(ownership=own))
446
+ self.assertEqual(f[0]["advice"], "Pair someone with Bob on core/ first; it is the largest at 300 lines.")
447
+ self.assertNotIn("vendor/", f[0]["detail"])
448
+ self.assertNotIn("web/", f[0]["detail"])
449
+ self.assertIn("100% of all lines added", f[0]["detail"], "vendored lines are not in the denominator either")
450
+
405
451
  def test_nothing_when_shared(self):
406
452
  self.assertEqual(findings.knowledge_islands(report(ownership=self.OWN[2:])), [])
407
453
  self.assertEqual(findings.knowledge_islands(report()), [])
@@ -244,6 +244,7 @@ class Report(unittest.TestCase):
244
244
  self.assertEqual(caption(r, True), "trend sampled for the top 10 hotspots")
245
245
  self.assertIsNone(caption(r, False), "the tight report keeps its captions short")
246
246
  r["revisions"] = [{"entity": f"f{i}.py", "n-revs": 100 - i} for i in range(60)]
247
+ r["size"]["files"].update({f"f{i}.py": {"code": 10, "complexity": 0} for i in range(60)}) # in the tree, so not hidden as deleted
247
248
  self.assertEqual(caption(r, "markdown"), "and 10 more; trend sampled for the top 10 hotspots")
248
249
 
249
250
  def test_watch_list_caption_reports_the_backtest_or_why_not(self):
@@ -289,10 +290,10 @@ class Report(unittest.TestCase):
289
290
  def test_default_coupling_hides_test_pairs_and_says_so(self):
290
291
  r = sample_report()
291
292
  r["coupling"].append({"entity": "static/tax.html", "coupled": "tests/test_tax.py", "degree": 100, "average-revs": 11})
292
- text = rendered(r, [])
293
+ text = rendered(r, [], width=200)
293
294
  coupling = text[text.index("Change coupling"):]
294
295
  self.assertNotIn("tests/test_tax.py", coupling)
295
- self.assertIn("1 test pair hidden; --full shows them", coupling)
296
+ self.assertIn("1 test pair hidden; 1 historical pair hidden; --full shows them", coupling, "one suffix for every hidden count")
296
297
  full_text = rendered(r, [], full=True)
297
298
  self.assertIn("tests/test_tax.py", full_text[full_text.index("Change coupling"):])
298
299
 
@@ -306,6 +307,16 @@ class Report(unittest.TestCase):
306
307
  full_text = rendered(r, [], full=True)
307
308
  self.assertIn("tests/test_a.py", full_text[full_text.index("Complex functions"):])
308
309
 
310
+ def test_default_complex_functions_hide_vendored_code_and_say_so(self):
311
+ r = sample_report()
312
+ r["functions"].append({"file": "vendor/github.com/x/y.go", "function": "validate", "ccn": 179, "nloc": 424, "params": 3, "start": 1, "end": 424})
313
+ r["functions"].append({"file": "tests/test_a.py", "function": "test_thing", "ccn": 40, "nloc": 50, "params": 0, "start": 1, "end": 50})
314
+ fn = _section_text(rendered(r, [], width=200), "Complex functions")
315
+ self.assertNotIn("vendor/", fn)
316
+ self.assertIn("1 function in a test file hidden; 1 function in vendored code hidden; --full shows them", fn)
317
+ full = _section_text(rendered(r, [], width=200, full=True), "Complex functions")
318
+ self.assertIn("vendor/github.com/x/y.go", full)
319
+
309
320
  def test_hotspots_with_only_test_files_say_what_was_hidden(self):
310
321
  r = sample_report()
311
322
  r["revisions"] = [{"entity": "tests/test_a.py", "n-revs": 200}]
@@ -325,6 +336,41 @@ class Report(unittest.TestCase):
325
336
  self.assertIn("static/tax.html", full)
326
337
  self.assertNotIn("hidden", full)
327
338
 
339
+ def test_default_hotspots_hide_deleted_files_and_say_so(self):
340
+ r = sample_report() # the tree holds static/index.html and static/apps-metadata.json only
341
+ r["revisions"].append({"entity": "src/sizes/old.go", "n-revs": 40})
342
+ hot = _section_text(rendered(r, [], width=200), "◆ Hotspots")
343
+ self.assertNotIn("src/sizes/old.go", hot)
344
+ self.assertIn("1 deleted file hidden; --full shows them", hot)
345
+ full = _section_text(rendered(r, [], width=200, full=True), "◆ Hotspots")
346
+ self.assertIn("src/sizes/old.go", full)
347
+ self.assertNotIn("hidden", full)
348
+
349
+ def test_hotspots_without_a_tree_listing_hide_nothing(self):
350
+ r = sample_report()
351
+ r["size"]["files"] = {}
352
+ hot = _section_text(rendered(r, [], width=200), "◆ Hotspots")
353
+ self.assertIn("static/index.html", hot)
354
+ self.assertNotIn("deleted", hot)
355
+
356
+ def test_default_coupling_collapses_a_directory_that_changes_as_one(self):
357
+ r = sample_report()
358
+ files = [f"rich/_unicode_data/unicode{n}.py" for n in ("10", "11", "12", "13")]
359
+ for f in files:
360
+ r["size"]["files"][f] = {"code": 600, "complexity": 0}
361
+ r["coupling"] = [{"entity": a, "coupled": b, "degree": 100, "average-revs": 5} for i, a in enumerate(files) for b in files[i + 1:]]
362
+ r["coupling"].append({"entity": "static/index.html", "coupled": "static/apps-metadata.json", "degree": 90, "average-revs": 11})
363
+ coupling = _section_text(rendered(r, [], width=200), "Change coupling")
364
+ self.assertIn("rich/_unicode_data/ (4 files)", coupling)
365
+ self.assertIn("each other", coupling)
366
+ self.assertIn("≥100%", coupling)
367
+ self.assertNotIn("unicode10", coupling)
368
+ self.assertIn("static/index.html", coupling)
369
+ self.assertIn("6 pairs in 1 directory shown as one row; --full shows them", coupling)
370
+ full = _section_text(rendered(r, [], width=200, full=True), "Change coupling")
371
+ self.assertIn("unicode10", full)
372
+ self.assertNotIn("each other", full)
373
+
328
374
  def test_coupling_with_only_test_pairs_says_what_was_hidden(self):
329
375
  r = sample_report()
330
376
  r["coupling"] = [{"entity": "static/tax.html", "coupled": "tests/test_tax.py", "degree": 100, "average-revs": 11}]
@@ -866,6 +912,7 @@ class Markdown(unittest.TestCase):
866
912
  r = sample_report()
867
913
  r["coupling"] = []
868
914
  r["revisions"] = [{"entity": "weird|name.py", "n-revs": 3}]
915
+ r["size"]["files"]["weird|name.py"] = {"code": 5, "complexity": 0} # in the tree, so not hidden as deleted
869
916
  md = render.markdown(r, [])
870
917
  self.assertIn("weird\\|name.py", md)
871
918
  self.assertIn("_no pairs with 5+ shared revisions_", md)
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