gitmole 0.11.0__tar.gz → 0.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {gitmole-0.11.0 → gitmole-0.12.0}/PKG-INFO +1 -1
  2. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/__init__.py +1 -1
  3. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/coupling.py +22 -0
  4. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/evaluate.py +4 -2
  5. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/findings.py +30 -2
  6. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/load.py +2 -1
  7. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/maat.py +139 -9
  8. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/render.py +3 -0
  9. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/run.py +1 -0
  10. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/watch.py +10 -0
  11. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole.egg-info/PKG-INFO +1 -1
  12. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_coupling.py +16 -0
  13. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_evaluate.py +5 -0
  14. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_findings.py +27 -1
  15. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_load.py +2 -0
  16. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_maat.py +93 -3
  17. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_render.py +11 -0
  18. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_run.py +1 -0
  19. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_watch.py +17 -0
  20. {gitmole-0.11.0 → gitmole-0.12.0}/LICENSE +0 -0
  21. {gitmole-0.11.0 → gitmole-0.12.0}/README.md +0 -0
  22. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/__main__.py +0 -0
  23. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/backtest.py +0 -0
  24. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/banner.py +0 -0
  25. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/blame.py +0 -0
  26. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/classify.py +0 -0
  27. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/clean.py +0 -0
  28. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/cli.py +0 -0
  29. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/compare.py +0 -0
  30. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/deps.py +0 -0
  31. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/duplicates.py +0 -0
  32. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/filetypes.py +0 -0
  33. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/functions.py +0 -0
  34. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/hotspots.py +0 -0
  35. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/identity.py +0 -0
  36. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/knowledge.py +0 -0
  37. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/leaks.py +0 -0
  38. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/loss.py +0 -0
  39. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/textfmt.py +0 -0
  40. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole/trend.py +0 -0
  41. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole.egg-info/SOURCES.txt +0 -0
  42. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole.egg-info/dependency_links.txt +0 -0
  43. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole.egg-info/entry_points.txt +0 -0
  44. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole.egg-info/requires.txt +0 -0
  45. {gitmole-0.11.0 → gitmole-0.12.0}/gitmole.egg-info/top_level.txt +0 -0
  46. {gitmole-0.11.0 → gitmole-0.12.0}/pyproject.toml +0 -0
  47. {gitmole-0.11.0 → gitmole-0.12.0}/setup.cfg +0 -0
  48. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_backtest.py +0 -0
  49. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_banner.py +0 -0
  50. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_blame.py +0 -0
  51. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_classify.py +0 -0
  52. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_clean.py +0 -0
  53. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_cli.py +0 -0
  54. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_compare.py +0 -0
  55. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_deps.py +0 -0
  56. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_duplicates.py +0 -0
  57. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_filetypes.py +0 -0
  58. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_functions.py +0 -0
  59. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_golden.py +0 -0
  60. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_hotspots.py +0 -0
  61. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_identity.py +0 -0
  62. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_knowledge.py +0 -0
  63. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_leaks.py +0 -0
  64. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_loss.py +0 -0
  65. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_packaging.py +0 -0
  66. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_render_examples.py +0 -0
  67. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_textfmt.py +0 -0
  68. {gitmole-0.11.0 → gitmole-0.12.0}/tests/test_trend.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.11.0
3
+ Version: 0.12.0
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.11.0"
3
+ __version__ = "0.12.0"
@@ -7,6 +7,28 @@ from collections import defaultdict
7
7
 
8
8
  ROOT = "(root files)"
9
9
 
10
+ SQUASH_SHARE = 0.5 # this share of subjects ending in (#NNNN), with almost no merge commits, is a squash-merged repository
11
+ FEW_MERGES = 0.02 # under this share of commits with two parents counts as almost none
12
+ MANY_MERGES = 0.1 # this share of merge commits and the branches' own commits are what the pairs describe
13
+
14
+
15
+ def regime(report: dict) -> tuple:
16
+ """(kind, caveat): how changes reach the branch, from what the history declares. 'squash' when
17
+ almost no commit has two parents and most subjects carry GitHub's squash suffix, so a coupling
18
+ pair describes a pull request, not an edit; 'merge' when a tenth or more of the commits are merges,
19
+ which export no file list; 'linear' otherwise, with nothing to caveat. An output directory from
20
+ before the merge count says 'linear' too."""
21
+ meta, act = report.get("meta") or {}, report.get("activity") or {}
22
+ commits, merges, squash = meta.get("commits") or 0, meta.get("merges"), act.get("squash_subjects") or 0
23
+ if not commits or merges is None:
24
+ return "linear", None
25
+ if merges / commits < FEW_MERGES and squash / commits >= SQUASH_SHARE:
26
+ return "squash", (f"{round(100 * squash / commits)}% of subjects end in (#NNNN) and {merges:,} of {commits:,} commits are merges: "
27
+ "squash-merged, so the pairs describe pull requests, not edits")
28
+ if merges / commits >= MANY_MERGES:
29
+ return "merge", f"{merges:,} of {commits:,} commits are merges: merge commits carry no file list, so the pairs describe the commits on the branches"
30
+ return "linear", None
31
+
10
32
 
11
33
  def _dir(path: str) -> str:
12
34
  head = os.path.dirname(path)
@@ -37,8 +37,10 @@ def months_after(date: str, months: int) -> str:
37
37
 
38
38
 
39
39
  def fixed_between(commits: list, start: str, end: str) -> set:
40
- """Source files a fix commit touched on or after `start` and before `end`. Test files change with every fix."""
41
- return {p for c in maat.in_window(commits, start, end) if maat.is_fix(c.get("subject", ""))
40
+ """Source files a fix commit touched on or after `start` and before `end`. Test files change with
41
+ every fix; an oversized fix (over the whole history's 99th percentile of lines) is tangled by size."""
42
+ big = {c["hash"] for c in maat.oversized(commits)}
43
+ return {p for c in maat.in_window(commits, start, end) if maat.is_fix(c.get("subject", "")) and c["hash"] not in big
42
44
  for p, _, _ in c["files"] if not filetypes.is_test_path(p)}
43
45
 
44
46
 
@@ -229,6 +229,33 @@ def sweeping_commits(report: dict) -> list:
229
229
  "deleted": c.get("deleted"), "subject": c.get("subject", "")} for c in swept[:10]]})]
230
230
 
231
231
 
232
+ def tangled_commits(report: dict, min_share: float = 0.02, min_count: int = 5) -> list:
233
+ """Commits that do several things at once: ten or more files across four or more directories under
234
+ a subject that lists several changes. Herzig and Zeller (MSR 2013) found tangled fixes mislabel a
235
+ large share of the files they touch; the fix pool already leaves out fixes over the repository's
236
+ 99th percentile of lines. A habit is worth a line, one such commit in a hundred is not."""
237
+ act = report.get("activity") or {}
238
+ count, listed = act.get("tangled_commits") or 0, act.get("tangled") or []
239
+ total = report["meta"].get("commits") or 0
240
+ if not count or not total or count < min_count or count / total < min_share:
241
+ return []
242
+ big = act.get("oversized_fixes") or 0
243
+
244
+ def one(c):
245
+ return f"{c['hash']} ({c['files']:,} files, {c['dirs']} directories, {textfmt.cut(c['subject'], 70)})"
246
+ sample = "; ".join(one(c) for c in listed[:3]) + (f" and {count - 3} more" if count > 3 and len(listed) >= 3 else "")
247
+ aside = (f", so {big} {'fix' if big == 1 else 'fixes'} over the repository's 99th percentile of lines changed {'is' if big == 1 else 'are'} already left out of the fix counts"
248
+ if big else "")
249
+ return [_f("info", "Tangled commits",
250
+ f"{count:,} of {total:,} commits ({_pct(count, total)}) touch {maat.TANGLED_FILES} or more files across {maat.TANGLED_DIRS} or more directories "
251
+ f"under a subject that lists several changes: {sample}. A fix among them credits every file it touched{aside}.",
252
+ "Split a change that does several things before merge; the fix history stays readable and the coupling stays real.",
253
+ rule={"id": "tangled_commits", "min_files": maat.TANGLED_FILES, "min_dirs": maat.TANGLED_DIRS, "min_clauses": maat.TANGLED_CLAUSES,
254
+ "min_share": min_share, "min_count": min_count},
255
+ evidence={"count": count, "commits": total, "oversized_fixes": big,
256
+ "sample": [{"hash": c["hash"], "date": c["date"], "files": c["files"], "dirs": c["dirs"], "subject": c["subject"]} for c in listed[:10]]})]
257
+
258
+
232
259
  def minor_contributors(report: dict, min_minor: int = 5, warn_at: int = 10, top_n: int = 10) -> list:
233
260
  """Top hotspots with a crowd of minor contributors, people with under 5% of the file's commits
234
261
  each. Bird et al. ("Don't Touch My Code!", FSE 2011) found that count the strongest ownership
@@ -377,7 +404,8 @@ def bug_magnets(report: dict, min_recent: int = 3, warn_at: int = 5) -> list:
377
404
  return [_f(sev, "Bug magnets",
378
405
  f"{len(hot)} file(s) were fixed {min_recent}+ times in the last six months: {listed}{more}.",
379
406
  f"Review {first} before the next release; fixes keep landing there.",
380
- rule={"id": "bug_magnets", "min_recent": min_recent, "warn_at": warn_at, "window_months": 6, "fix": "the commit subject says so"},
407
+ rule={"id": "bug_magnets", "min_recent": min_recent, "warn_at": warn_at, "window_months": 6, "fix": "the commit subject says so",
408
+ "oversized": "a fix over the repository's 99th percentile of lines changed credits nothing"},
381
409
  evidence={"count": len(hot), "files": [{"file": f["entity"], "recent_fixes": f["recent-fixes"], "fixes": f["n-fixes"]} for f in hot[:10]]})]
382
410
 
383
411
 
@@ -705,7 +733,7 @@ def vulnerable_dependencies(report: dict) -> list:
705
733
 
706
734
  RULES = [dormant, secrets_found, credential_files, vulnerable_dependencies, placeholder_identity, bus_factor, sizer_concerns, hotspot_dominance, bug_magnets,
707
735
  minor_contributors, reverts, brain_methods, complexity_growth, tight_coupling, duplication, stale_files, knowledge_islands, knowledge_loss,
708
- sweeping_commits]
736
+ sweeping_commits, tangled_commits]
709
737
 
710
738
 
711
739
  def evaluate(report: dict) -> list:
@@ -82,7 +82,7 @@ def parse_scc(text: str, types=None) -> dict:
82
82
 
83
83
 
84
84
  NUMERIC_COLUMNS = {"n-revs", "degree", "average-revs", "n-authors", "age-months", "added", "deleted", "n-fixes", "recent-fixes", "tiny-revs",
85
- "minor", "soc", "partners"}
85
+ "minor", "soc", "partners", "n-sets", "with-tests"}
86
86
 
87
87
 
88
88
  def parse_maat_csv(text: str) -> list:
@@ -308,6 +308,7 @@ def load_report(out_dir: str, nested: bool = True) -> dict:
308
308
  "plumbing": parse_maat_csv(_read(out_dir, "maat-plumbing.csv")),
309
309
  "coupling": parse_maat_csv(_read(out_dir, "maat-coupling.csv")),
310
310
  "soc": parse_maat_csv(_read(out_dir, "maat-soc.csv")), # sum of coupling; empty for an output directory from before 0.11
311
+ "tests": parse_maat_csv(_read(out_dir, "maat-tests.csv")), # test co-change per production file; empty before 0.12
311
312
  "authors": parse_maat_csv(_read(out_dir, "maat-authors.csv")),
312
313
  "age": parse_maat_csv(_read(out_dir, "maat-age.csv")),
313
314
  "ownership": ownership,
@@ -182,10 +182,69 @@ def revisions(commits: list) -> list:
182
182
  return [{"entity": e, "n-revs": n} for e, n in sorted(_revs(commits).items(), key=lambda kv: (-kv[1], kv[0]))]
183
183
 
184
184
 
185
+ # A ticket-shaped key in a subject: GitHub's squash-merge suffix "(#1234)", a Jira-shaped "PROJ-42",
186
+ # or a reference such as "Fixes #77". Value shapes, not word lists: the shape is the convention.
187
+ _TICKET_SUFFIX = re.compile(r"\(#(\d+)\)\s*$")
188
+ _TICKET_JIRA = re.compile(r"^\s*[\[(]?([A-Z][A-Z0-9]+-\d+)\b") # at the start, as trackers put it; UTF-8 mid-sentence is a word
189
+ _TICKET_REF = re.compile(r"\b(?:fixes|fixed|closes|closed|refs|resolves|resolved)\s+#?(\d+)\b", re.I)
190
+
191
+
192
+ def ticket_key(subject: str):
193
+ """The change a commit belongs to, when its subject says: '#1234' for a squash suffix or a
194
+ 'Fixes #1234' reference, 'PROJ-42' for a Jira-shaped key opening the subject; None when nothing
195
+ ticket-shaped is there."""
196
+ subject = subject or ""
197
+ m = _TICKET_SUFFIX.search(subject)
198
+ if m:
199
+ return f"#{m.group(1)}"
200
+ m = _TICKET_JIRA.search(subject)
201
+ if m:
202
+ return m.group(1)
203
+ m = _TICKET_REF.search(subject)
204
+ if m:
205
+ return f"#{m.group(1)}"
206
+ return None
207
+
208
+
209
+ def changesets(commits: list) -> list:
210
+ """The logical changes, code-maat's temporal period with CodeScene's ticket grouping: commits whose
211
+ subjects share a ticket-shaped key are one changeset, wherever and whenever they landed; the rest
212
+ group by author and calendar day, so a rebase-merged pull request is one change again. Two squash
213
+ merges by one person on one day carry two keys and stay apart. Each changeset speaks with its first
214
+ commit's hash, date, author and subject, carries its commit count, and sums the lines per path."""
215
+ groups, order = {}, []
216
+ for c in commits:
217
+ key = ticket_key(c.get("subject", ""))
218
+ key = ("ticket", key) if key else ("day", c["author"], c["date"])
219
+ if key not in groups:
220
+ groups[key] = {"hash": c["hash"], "date": c["date"], "time": c.get("time", c["date"]), "author": c["author"],
221
+ "subject": c.get("subject", ""), "co_authors": list(c.get("co_authors", ())), "commits": 0, "files": [], "_lines": {}}
222
+ order.append(key)
223
+ g = groups[key]
224
+ g["commits"] += 1
225
+ for p, a, d in c["files"]:
226
+ if p not in g["_lines"]:
227
+ g["_lines"][p] = [a, d]
228
+ else:
229
+ g["_lines"][p][0] += a
230
+ g["_lines"][p][1] += d
231
+ out = []
232
+ for key in order:
233
+ g = groups[key]
234
+ g["files"] = [(p, a, d) for p, (a, d) in g["_lines"].items()]
235
+ del g["_lines"]
236
+ out.append(g)
237
+ return out
238
+
239
+
185
240
  def coupling(commits: list, min_shared: int = 5, min_degree: int = 30, max_changeset: int = 30) -> list:
186
- revs = _revs(commits)
241
+ """Pairs that change together, over the logical changesets (see changesets()) rather than the raw
242
+ commits, so a rebase-merged change counts once and a change spread over a ticket's commits counts
243
+ as one. The cap on a changeset's size applies after grouping."""
244
+ sets = changesets(commits)
245
+ revs = _revs(sets)
187
246
  shared = Counter()
188
- for c in commits:
247
+ for c in sets:
189
248
  paths = sorted({p for p, _, _ in c["files"]})
190
249
  if len(paths) > max_changeset:
191
250
  continue
@@ -228,7 +287,7 @@ def soc(commits: list, min_shared: int = 5, max_changeset: int = 30) -> list:
228
287
  and how many distinct files it shares at least `min_shared` commits with. Pairwise degree finds
229
288
  the pairs; this finds the file weakly coupled to everything."""
230
289
  shared = Counter()
231
- for c in commits:
290
+ for c in changesets(commits):
232
291
  paths = sorted({p for p, _, _ in c["files"]})
233
292
  if len(paths) > max_changeset:
234
293
  continue
@@ -272,16 +331,56 @@ def age(commits: list, now: str = None) -> list:
272
331
  return rows
273
332
 
274
333
 
334
+ def test_cochange(commits: list) -> list:
335
+ """Per production file: how many changesets touched it, and how many of those also touched a test
336
+ file. A hot file whose tests never move is a better-evidenced test signal than assertion density."""
337
+ sets_by, with_tests = Counter(), Counter()
338
+ for c in changesets(commits):
339
+ paths = {p for p, _, _ in c["files"]}
340
+ tested = any(filetypes.is_test_path(p) for p in paths)
341
+ for p in paths:
342
+ if filetypes.is_test_path(p):
343
+ continue
344
+ sets_by[p] += 1
345
+ if tested:
346
+ with_tests[p] += 1
347
+ rows = [{"entity": p, "n-sets": n, "with-tests": with_tests[p]} for p, n in sets_by.items()]
348
+ rows.sort(key=lambda r: (-r["n-sets"], r["entity"]))
349
+ return rows
350
+
351
+
275
352
  RECENT_MONTHS = 6
353
+ OVERSIZED_PERCENTILE = 0.99 # a fix changing more lines than this share of the history's commits credits nothing
354
+ OVERSIZED_FLOOR = 500 # ...and never under this many lines, so a small repository's percentile does not bite
355
+
356
+
357
+ def _lines(c: dict) -> int:
358
+ return sum(a + d for _, a, d in c["files"])
359
+
360
+
361
+ def oversized(commits: list, percentile: float = OVERSIZED_PERCENTILE, floor: int = OVERSIZED_FLOOR) -> list:
362
+ """The commits above the repository's own 99th percentile of lines changed (never under `floor`).
363
+ Commit sizes are heavy-tailed, so the percentile is the repository's; Herzig and Zeller showed a
364
+ tangled fix mislabels most of the files it touches, and a fix this size is tangled by size."""
365
+ sizes = sorted(_lines(c) for c in commits if c["files"])
366
+ if not sizes:
367
+ return []
368
+ cut = max(floor, sizes[min(len(sizes) - 1, int(math.ceil(percentile * len(sizes))) - 1)])
369
+ return [c for c in commits if c["files"] and _lines(c) >= cut]
370
+
371
+
372
+ def fix_commits(commits: list) -> list:
373
+ """The fix pool: commits whose subject says fix, less the oversized ones."""
374
+ big = {c["hash"] for c in oversized(commits)}
375
+ return [c for c in commits if is_fix(c.get("subject", "")) and c["hash"] not in big]
276
376
 
277
377
 
278
378
  def fixes(commits: list, now: str = None) -> list:
279
- """Per entity: how many fix commits touched it, the last one, and how many in the recent window."""
379
+ """Per entity: how many fix commits touched it, the last one, and how many in the recent window.
380
+ An oversized fix (see oversized()) credits none of its files."""
280
381
  now = now or dt.date.today().isoformat()
281
382
  total, last, recent = Counter(), {}, Counter()
282
- for c in commits:
283
- if not is_fix(c.get("subject", "")):
284
- continue
383
+ for c in fix_commits(commits):
285
384
  fresh = _months_between(c["date"], now) < RECENT_MONTHS
286
385
  for p, _, _ in c["files"]:
287
386
  total[p] += 1
@@ -330,10 +429,33 @@ def author_totals(commits: list) -> dict:
330
429
  return out
331
430
 
332
431
 
432
+ TANGLED_FILES = 10 # a commit this wide, across this many directories, under a subject with this many clauses,
433
+ TANGLED_DIRS = 4 # looks like several changes in one; Herzig and Zeller (MSR 2013) found such commits
434
+ TANGLED_CLAUSES = 2 # mislabel a large share of the files they touch
435
+ _BRACKETED = re.compile(r"\([^()]*\)|\[[^\[\]]*\]|`[^`]*`")
436
+ _CLAUSE_BREAK = re.compile(r"\s*(?:;|,|&|\+|\band\b)\s*", re.I)
437
+
438
+
439
+ def clauses(subject: str) -> int:
440
+ """How many things a subject says it does: its parts between semicolons, commas, ampersands, pluses
441
+ and 'and', with anything in brackets or backticks passed over (a call's arguments are not clauses)."""
442
+ return len([part for part in _CLAUSE_BREAK.split(_BRACKETED.sub("", subject or "")) if part.strip()])
443
+
444
+
445
+ def is_tangled(c: dict) -> bool:
446
+ """Many files across many directories under a subject that lists several things: several changes
447
+ in one commit, whose fix label, if any, credits files the fix never touched."""
448
+ if len(c["files"]) < TANGLED_FILES:
449
+ return False
450
+ dirs = {os.path.dirname(p) for p, _, _ in c["files"]}
451
+ return len(dirs) >= TANGLED_DIRS and clauses(c.get("subject", "")) >= TANGLED_CLAUSES
452
+
453
+
333
454
  def activity(commits: list, ignored: set = frozenset()) -> dict:
334
455
  """Commits by weekday (Mon=0) and hour, by month, and per-author totals, over every commit; plus
335
456
  the sweeping commits the tables leave out, each marked whether the repository declared it in
336
- .git-blame-ignore-revs, and how many declared commits the log holds."""
457
+ .git-blame-ignore-revs, how many declared commits the log holds, the oversized fixes the fix pool
458
+ leaves out, the tangled-looking commits, and how many subjects end in a squash-merge suffix."""
337
459
  by_weekday, by_hour, by_month, net_by_year = [0] * 7, [0] * 24, Counter(), Counter()
338
460
  timeline, fix_commits = defaultdict(Counter), 0
339
461
  revert_commits, reverted = 0, Counter()
@@ -362,6 +484,8 @@ def activity(commits: list, ignored: set = frozenset()) -> dict:
362
484
  if is_rev:
363
485
  revert_commits += 1
364
486
  swept = sorted(sweeping(commits), key=lambda c: (-len(c["files"]), c["date"], c["hash"]))
487
+ big = {c["hash"] for c in oversized(commits)}
488
+ tangled = sorted((c for c in commits if is_tangled(c)), key=lambda c: (-len(c["files"]), c["date"], c["hash"]))
365
489
  return {"by_weekday": by_weekday, "by_hour": by_hour, "by_month": dict(sorted(by_month.items())),
366
490
  "net_by_year": dict(sorted(net_by_year.items())), "authors": author_totals(commits),
367
491
  "timeline": {a: dict(sorted(m.items())) for a, m in timeline.items()}, "fix_commits": fix_commits,
@@ -370,7 +494,12 @@ def activity(commits: list, ignored: set = frozenset()) -> dict:
370
494
  "sweeping": [{"hash": c["hash"], "date": c["date"], "author": c["author"], "subject": c.get("subject", ""), "files": len(c["files"]),
371
495
  "added": sum(a for _, a, _ in c["files"]), "deleted": sum(d for _, _, d in c["files"]), "declared": c["hash"] in ignored}
372
496
  for c in swept],
373
- "ignored_revs": sum(1 for c in commits if c["hash"] in ignored)}
497
+ "ignored_revs": sum(1 for c in commits if c["hash"] in ignored),
498
+ "oversized_fixes": sum(1 for c in commits if c["hash"] in big and is_fix(c.get("subject", ""))),
499
+ "tangled_commits": len(tangled),
500
+ "tangled": [{"hash": c["hash"], "date": c["date"], "files": len(c["files"]), "dirs": len({os.path.dirname(p) for p, _, _ in c["files"]}),
501
+ "subject": c.get("subject", "")} for c in tangled[:10]],
502
+ "squash_subjects": sum(1 for c in commits if _TICKET_SUFFIX.search(c.get("subject", "")))}
374
503
 
375
504
 
376
505
  def plumbing(commits: list, min_revs: int = 20, share: float = 0.8, max_lines: int = 3) -> list:
@@ -392,6 +521,7 @@ ANALYSES = {
392
521
  "plumbing": (plumbing, ["entity", "n-revs", "tiny-revs"]),
393
522
  "coupling": (coupling, ["entity", "coupled", "degree", "average-revs"]),
394
523
  "soc": (soc, ["entity", "soc", "partners"]),
524
+ "tests": (test_cochange, ["entity", "n-sets", "with-tests"]),
395
525
  "authors": (authors, ["entity", "n-authors", "n-revs", "minor"]),
396
526
  "age": (age, ["entity", "age-months"]),
397
527
  "entity-ownership": (entity_ownership, ["entity", "author", "added", "deleted"]),
@@ -537,6 +537,9 @@ def coupling_section(report: dict, full: bool = True, width=None) -> dict:
537
537
  rows = _shorten(rows, width, columns, path_columns=2)
538
538
  note = None if rows else _empty_note("no pairs with 5+ shared revisions", hidden_note, "no source pairs with 5+ shared revisions")
539
539
  notes = [c for c in (_more(len(pairs), limit), None if note else hidden_note) if c]
540
+ caveat = coupling.regime(report)[1] # what a pair means here: a pull request under squash merging, an edit otherwise
541
+ if rows and caveat:
542
+ notes.append(caveat)
540
543
  return _section("Change coupling", columns, rows, note=note, caption="; ".join(notes) or None)
541
544
 
542
545
 
@@ -521,6 +521,7 @@ def collect_meta(repo_dir: str, since: str = None) -> dict:
521
521
  "path": repo_dir,
522
522
  "branch": _git(repo_dir, "rev-parse", "--abbrev-ref", "HEAD").strip(),
523
523
  "commits": len(dates),
524
+ "merges": int(_git(repo_dir, "rev-list", "--count", "--merges", "HEAD").strip() or 0), # the merge regime: squash, merge commits or linear
524
525
  "first_date": min(dates) if dates else "",
525
526
  "first_date_all": min(all_dates) if all_dates else "", # unwindowed: the backtest asks how long the history is
526
527
  "last_date": max(dates) if dates else "",
@@ -25,6 +25,8 @@ COMPANION_DEGREE = 50 # a coupling worth mentioning
25
25
  COMPANION_REVS = 5 # ...over enough shared revisions to be a pattern
26
26
  MINOR_FLOOR = 3 # this many minor contributors (under 5% of the file's commits each) is a crowd worth naming
27
27
  PARTNERS_FLOOR = 20 # this many files it shares five or more commits with is a hub worth naming
28
+ TESTED_SETS = 5 # this many changes before the share of them that moved a test says anything
29
+ TESTED_SHARE = 0.2 # a test moved with at most this share of the file's changes: a hot file whose tests do not follow it
28
30
 
29
31
 
30
32
  def _owners(report: dict) -> dict:
@@ -70,6 +72,9 @@ def risks(report: dict, min_revs: int = 2) -> list:
70
72
  n_authors = {a["entity"]: a["n-authors"] for a in report.get("authors") or []}
71
73
  minors = {a["entity"]: a.get("minor", 0) for a in report.get("authors") or []} # absent in an output directory from before 0.11
72
74
  partners = {a["entity"]: a.get("partners", 0) for a in report.get("soc") or []}
75
+ # absent before 0.12; and a repository without a test file anywhere has nothing to say about tests moving
76
+ has_tests = any(filetypes.is_test_path(p) for p in ((report.get("size") or {}).get("files") or {}))
77
+ tested = {t["entity"]: (t["n-sets"], t["with-tests"]) for t in report.get("tests") or []} if has_tests else {}
73
78
  series = (report.get("trend") or {}).get("files") or {}
74
79
  last = (report.get("meta") or {}).get("last_date") or ""
75
80
 
@@ -86,6 +91,8 @@ def risks(report: dict, min_revs: int = 2) -> list:
86
91
  rows.append({"file": h["entity"], "revs": h["revs"], "recent_fixes": fx.get("recent-fixes", 0), "fixes": fx.get("n-fixes", 0),
87
92
  "authors": n_authors.get(h["entity"]), "owner": owner, "owner_share": share,
88
93
  "minor": minors.get(h["entity"], 0), "partners": partners.get(h["entity"], 0),
94
+ "changes": tested.get(h["entity"], (None, None))[0], "with_tests": tested.get(h["entity"], (None, None))[1],
95
+ "tested_share": (tested[h["entity"]][1] / tested[h["entity"]][0]) if tested.get(h["entity"], (0, 0))[0] else None,
89
96
  "complexity": h["complexity"] or 0, "code": h["code"],
90
97
  "function": fn, "companions": companions.get(h["entity"], []),
91
98
  "trend": trend.change_over_year(series[h["entity"]], last) if last and h["entity"] in series else None})
@@ -149,6 +156,9 @@ def _reasons(r: dict) -> list:
149
156
  out.append(f"changes with {other} ({degree}%){tail}")
150
157
  if r.get("partners", 0) >= PARTNERS_FLOOR:
151
158
  out.append(f"changes alongside {r['partners']} other files") # sum of coupling: weakly coupled to everything
159
+ if r.get("changes") and r["changes"] >= TESTED_SETS and r["tested_share"] <= TESTED_SHARE:
160
+ out.append(f"no test changed in its {r['changes']} changes" if not r["with_tests"]
161
+ else f"a test changed in {r['with_tests']} of its {r['changes']} changes")
152
162
  return out
153
163
 
154
164
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.11.0
3
+ Version: 0.12.0
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
@@ -66,3 +66,19 @@ class Clusters(unittest.TestCase):
66
66
  groups, rest = coupling.clusters(pairs)
67
67
  self.assertEqual(groups, [])
68
68
  self.assertEqual(rest, pairs)
69
+
70
+
71
+ class Regime(unittest.TestCase):
72
+ def test_few_merges_and_squash_suffixes_on_most_subjects_is_squash_merged(self):
73
+ r = {"meta": {"commits": 1000, "merges": 3}, "activity": {"squash_subjects": 820}}
74
+ self.assertEqual(coupling.regime(r), ("squash", "82% of subjects end in (#NNNN) and 3 of 1,000 commits are merges: squash-merged, so the pairs describe pull requests, not edits"))
75
+
76
+ def test_many_merges_is_merge_commits_and_the_rest_is_linear(self):
77
+ self.assertEqual(coupling.regime({"meta": {"commits": 1000, "merges": 250}, "activity": {"squash_subjects": 10}})[0], "merge")
78
+ self.assertEqual(coupling.regime({"meta": {"commits": 1000, "merges": 250}, "activity": {"squash_subjects": 10}})[1],
79
+ "250 of 1,000 commits are merges: merge commits carry no file list, so the pairs describe the commits on the branches")
80
+ self.assertEqual(coupling.regime({"meta": {"commits": 1000, "merges": 5}, "activity": {"squash_subjects": 30}}), ("linear", None),
81
+ "rebase-merged or committed straight to the branch: nothing to caveat")
82
+ self.assertEqual(coupling.regime({"meta": {"commits": 0}, "activity": {}}), ("linear", None))
83
+ self.assertEqual(coupling.regime({"meta": {"commits": 1000}, "activity": {"squash_subjects": 900}}), ("linear", None),
84
+ "an output directory from before the merge count records nothing")
@@ -33,6 +33,11 @@ class Outcome(unittest.TestCase):
33
33
  self.assertEqual(evaluate.fixed_between(COMMITS, "2025-06-01", "2025-12-01"), {"core/a.py", "core/b.py"})
34
34
  self.assertEqual(evaluate.fixed_between(COMMITS, "2025-03-10", "2025-03-11"), {"core/a.py"}, "the start is inclusive; the test file is left out")
35
35
 
36
+ def test_an_oversized_fix_is_not_an_outcome(self):
37
+ commits = COMMITS + [commit(f"2025-05-{1 + i:02d}", "small", ("core/a.py", 1, 1)) for i in range(20)]
38
+ commits.append(commit("2025-08-01", "fix: the big one", *[(f"core/g{i}.py", 100, 100) for i in range(10)]))
39
+ self.assertEqual(evaluate.fixed_between(commits, "2025-07-01", "2025-09-01"), {"core/b.py"}, "2,000 lines over the 99th percentile: tangled by size, credits nothing")
40
+
36
41
 
37
42
  class Score(unittest.TestCase):
38
43
  def test_the_report_at_t_knows_nothing_after_t(self):
@@ -289,6 +289,31 @@ class MinorContributors(unittest.TestCase):
289
289
  self.assertNotIn("tests/test_x.py", f["detail"])
290
290
 
291
291
 
292
+ class TangledCommits(unittest.TestCase):
293
+ def tangled(self, h, files, dirs, subject, date="2026-03-01"):
294
+ return {"hash": h, "date": date, "files": files, "dirs": dirs, "subject": subject}
295
+
296
+ def test_named_with_their_share_when_there_are_enough(self):
297
+ act = {"tangled_commits": 12, "tangled": [self.tangled("t1", 34, 9, "Fix the parser, add a cache and rename the helpers"),
298
+ self.tangled("t2", 12, 4, "Add x; fix y")], "oversized_fixes": 3}
299
+ [f] = findings.tangled_commits(report(activity=act))
300
+ self.assertEqual((f["severity"], f["title"]), ("info", "Tangled commits"))
301
+ self.assertIn("12 of 100 commits (12%) touch 10 or more files across 4 or more directories under a subject that lists several changes: "
302
+ "t1 (34 files, 9 directories, Fix the parser, add a cache and rename the helpers); t2 (12 files, 4 directories, Add x; fix y). "
303
+ "A fix among them credits every file it touched, so 3 fixes over the repository's 99th percentile of lines changed are already left out of the fix counts.", f["detail"])
304
+ self.assertEqual(f["advice"], "Split a change that does several things before merge; the fix history stays readable and the coupling stays real.")
305
+ self.assertEqual(f["rule"], {"id": "tangled_commits", "min_files": 10, "min_dirs": 4, "min_clauses": 2, "min_share": 0.02, "min_count": 5})
306
+ self.assertEqual(f["evidence"]["count"], 12)
307
+ self.assertEqual(f["evidence"]["oversized_fixes"], 3)
308
+
309
+ def test_nothing_below_the_share_or_the_count(self):
310
+ act = {"tangled_commits": 1, "tangled": [self.tangled("t1", 34, 9, "a, b")]}
311
+ self.assertEqual(findings.tangled_commits(report(activity=act)), [], "one in a hundred is noise")
312
+ act = {"tangled_commits": 6, "tangled": [self.tangled("t1", 34, 9, "a, b")]}
313
+ self.assertEqual(len(findings.tangled_commits(report(activity=act))), 1, "six of a hundred is a habit")
314
+ self.assertEqual(findings.tangled_commits(report()), [])
315
+
316
+
292
317
  class HotspotDominance(unittest.TestCase):
293
318
  def test_info_when_top_file_changes_twice_as_often_as_next(self):
294
319
  f = findings.hotspot_dominance(report(revisions=[{"entity": "meta.json", "n-revs": 128}, {"entity": "i.html", "n-revs": 51}]))
@@ -1005,7 +1030,8 @@ class Advice(unittest.TestCase):
1005
1030
  def test_a_bug_magnet_can_be_rechecked_from_its_own_rule_and_evidence(self):
1006
1031
  f = findings.bug_magnets(report(fixes=[{"entity": "a.py", "n-fixes": 9, "last-fix": "2026-09-01", "recent-fixes": 5},
1007
1032
  {"entity": "b.py", "n-fixes": 3, "last-fix": "2026-08-01", "recent-fixes": 3}]))[0]
1008
- self.assertEqual(f["rule"], {"id": "bug_magnets", "min_recent": 3, "warn_at": 5, "window_months": 6, "fix": "the commit subject says so"})
1033
+ self.assertEqual(f["rule"], {"id": "bug_magnets", "min_recent": 3, "warn_at": 5, "window_months": 6, "fix": "the commit subject says so",
1034
+ "oversized": "a fix over the repository's 99th percentile of lines changed credits nothing"})
1009
1035
  self.assertEqual(f["evidence"], {"count": 2, "files": [{"file": "a.py", "recent_fixes": 5, "fixes": 9}, {"file": "b.py", "recent_fixes": 3, "fixes": 3}]})
1010
1036
  self.assertTrue(all(x["recent_fixes"] >= f["rule"]["min_recent"] for x in f["evidence"]["files"]))
1011
1037
 
@@ -260,6 +260,7 @@ class LoadReport(unittest.TestCase):
260
260
  "maat-age.csv": "entity,age-months\na.py,0\n",
261
261
  "maat-authors.csv": "entity,n-authors,n-revs,minor\na.py,1,3,0\n",
262
262
  "maat-soc.csv": "entity,soc,partners\na.py,41,7\n",
263
+ "maat-tests.csv": "entity,n-sets,with-tests\na.py,3,1\n",
263
264
  "maat-fixes.csv": "entity,n-fixes,last-fix,recent-fixes\na.py,2,2026-01-05,1\n",
264
265
  "repo-health.txt": "",
265
266
  "secrets.json": "[]",
@@ -283,6 +284,7 @@ class LoadReport(unittest.TestCase):
283
284
  self.assertEqual(r["fixes"][0]["recent-fixes"], 1)
284
285
  self.assertEqual(r["authors"], [{"entity": "a.py", "n-authors": 1, "n-revs": 3, "minor": 0}])
285
286
  self.assertEqual(r["soc"], [{"entity": "a.py", "soc": 41, "partners": 7}], "sum of coupling, as numbers")
287
+ self.assertEqual(r["tests"], [{"entity": "a.py", "n-sets": 3, "with-tests": 1}], "test co-change, as numbers")
286
288
  self.assertEqual(r["cohorts"], {"Code added in 2026": 10})
287
289
  self.assertEqual(r["theseus_authors"], {"Ann": 10})
288
290
  self.assertEqual(r["sizer"], [])
@@ -350,7 +350,7 @@ class WriteAll(unittest.TestCase):
350
350
  maat.write_all(log, d)
351
351
  names = sorted(n for n in os.listdir(d) if n.startswith("maat-"))
352
352
  self.assertEqual(names, ["maat-age.csv", "maat-authors.csv", "maat-coupling.csv", "maat-entity-ownership.csv", "maat-fixes.csv",
353
- "maat-plumbing.csv", "maat-revisions.csv", "maat-soc.csv"])
353
+ "maat-plumbing.csv", "maat-revisions.csv", "maat-soc.csv", "maat-tests.csv"])
354
354
  self.assertTrue(os.path.isfile(os.path.join(d, "activity.json")))
355
355
  with open(os.path.join(d, "maat-revisions.csv")) as fh:
356
356
  self.assertEqual(fh.readline().strip(), "entity,n-revs")
@@ -442,8 +442,8 @@ class MinorContributors(unittest.TestCase):
442
442
 
443
443
  class SumOfCoupling(unittest.TestCase):
444
444
  def test_total_co_changes_and_partners_per_entity(self):
445
- commits = [_commit(f"c{i}", [("hub.py", 1, 0), (f"leaf{i % 3}.py", 1, 0)]) for i in range(15)]
446
- commits.append(_commit("big", [(f"f{i}.py", 1, 0) for i in range(40)]))
445
+ commits = [_commit(f"c{i}", [("hub.py", 1, 0), (f"leaf{i % 3}.py", 1, 0)], date=f"2026-01-{1 + i:02d}") for i in range(15)]
446
+ commits.append(_commit("big", [(f"f{i}.py", 1, 0) for i in range(40)], date="2026-02-01"))
447
447
  rows = {r["entity"]: r for r in maat.soc(commits)}
448
448
  self.assertEqual(rows["hub.py"], {"entity": "hub.py", "soc": 15, "partners": 3}, "15 co-changes over 3 files, each shared 5 times")
449
449
  self.assertEqual(rows["leaf0.py"], {"entity": "leaf0.py", "soc": 5, "partners": 1})
@@ -500,6 +500,96 @@ class Sweeping(unittest.TestCase):
500
500
  self.assertEqual([(s["hash"], s["declared"]) for s in act["sweeping"]], [("fmt1", True), ("ren1", False)])
501
501
 
502
502
 
503
+ class Changesets(unittest.TestCase):
504
+ def test_a_ticket_shaped_key_in_the_subject(self):
505
+ self.assertEqual(maat.ticket_key("Add the parser (#1234)"), "#1234", "GitHub's squash-merge suffix")
506
+ self.assertEqual(maat.ticket_key("PROJ-42: handle nulls"), "PROJ-42", "a Jira-shaped key")
507
+ self.assertEqual(maat.ticket_key("Handle nulls. Fixes #77"), "#77")
508
+ self.assertEqual(maat.ticket_key("Closes #77 and refs #78"), "#77", "the first reference names the change")
509
+ self.assertIsNone(maat.ticket_key("Handle nulls"))
510
+ self.assertIsNone(maat.ticket_key("Use UTF-8 everywhere"), "UTF-8 is a word, not a ticket: a key opens the subject")
511
+ self.assertEqual(maat.ticket_key("[PROJ-42] handle nulls"), "PROJ-42")
512
+ self.assertIsNone(maat.ticket_key("bump to 2024-01"), "a date is not a key")
513
+
514
+ def test_commits_sharing_a_key_are_one_changeset_the_rest_group_by_author_and_day(self):
515
+ commits = [_commit("a1", [("x.py", 1, 0)], author="Ann", date="2026-01-05", subject="PROJ-1 start"),
516
+ _commit("a2", [("y.py", 2, 0)], author="Bob", date="2026-01-09", subject="PROJ-1 finish"),
517
+ _commit("b1", [("p.py", 1, 0), ("x.py", 1, 1)], author="Ann", date="2026-01-05", subject="tidy"),
518
+ _commit("b2", [("q.py", 1, 0)], author="Ann", date="2026-01-05", subject="more tidy"),
519
+ _commit("c1", [("q.py", 3, 0)], author="Ann", date="2026-01-06", subject="next day"),
520
+ _commit("d1", [("r.py", 1, 0)], author="Cat", date="2026-01-05", subject="Cat's own (#9)"),
521
+ _commit("d2", [("s.py", 1, 0)], author="Cat", date="2026-01-05", subject="Cat again (#10)")]
522
+ sets = maat.changesets(commits)
523
+ self.assertEqual([(c["hash"], c["commits"], sorted(p for p, _, _ in c["files"])) for c in sets],
524
+ [("a1", 2, ["x.py", "y.py"]), ("b1", 2, ["p.py", "q.py", "x.py"]), ("c1", 1, ["q.py"]), ("d1", 1, ["r.py"]), ("d2", 1, ["s.py"])],
525
+ "a key wins over the day; two squash merges by one person on one day stay apart")
526
+ self.assertEqual(sets[1]["files"], [("p.py", 1, 0), ("x.py", 1, 1), ("q.py", 1, 0)], "lines summed per path, first seen first")
527
+ self.assertEqual((sets[0]["author"], sets[0]["date"], sets[0]["subject"]), ("Ann", "2026-01-05", "PROJ-1 start"), "the first commit speaks for the set")
528
+
529
+ def test_coupling_and_sum_of_coupling_count_changesets_not_commits(self):
530
+ commits = []
531
+ for i in range(6): # a rebase-merged feature: the model and its migration land as two commits by one author on one day
532
+ commits.append(_commit(f"m{i}", [("app/model.py", 5, 1)], author="Ann", date=f"2026-02-{10 + i:02d}", subject="model"))
533
+ commits.append(_commit(f"g{i}", [("app/migration.py", 5, 1)], author="Ann", date=f"2026-02-{10 + i:02d}", subject="migration"))
534
+ self.assertEqual(maat.coupling(commits), [{"entity": "app/migration.py", "coupled": "app/model.py", "degree": 100, "average-revs": 6}])
535
+ self.assertEqual(maat.soc(commits)[0], {"entity": "app/migration.py", "soc": 6, "partners": 1})
536
+
537
+ def test_the_changeset_cap_applies_after_grouping(self):
538
+ commits = [_commit(f"c{i}", [(f"f{i}.py", 1, 0), ("hub.py", 1, 0)], author="Ann", date="2026-03-01", subject="") for i in range(40)]
539
+ self.assertEqual(maat.coupling(commits), [], "forty small commits in a day are one sweep of forty files, over the cap")
540
+
541
+
542
+ class TestCoChange(unittest.TestCase):
543
+ def test_share_of_a_files_changesets_that_also_touched_a_test(self):
544
+ commits = [_commit(f"a{i}", [("core/a.py", 1, 0), ("tests/test_a.py", 1, 0)]) for i in range(3)]
545
+ commits += [_commit(f"b{i}", [("core/a.py", 1, 0)], date="2026-02-01") for i in range(1)]
546
+ commits += [_commit(f"c{i}", [("core/b.py", 1, 0)], date=f"2026-03-{1 + i:02d}") for i in range(4)]
547
+ rows = {r["entity"]: r for r in maat.test_cochange(commits)}
548
+ self.assertEqual(rows["core/a.py"], {"entity": "core/a.py", "n-sets": 2, "with-tests": 1},
549
+ "three same-day commits are one changeset with a test, the February one has none")
550
+ self.assertEqual(rows["core/b.py"], {"entity": "core/b.py", "n-sets": 4, "with-tests": 0})
551
+ self.assertNotIn("tests/test_a.py", rows, "a test file's own row would say nothing")
552
+
553
+
554
+ class OversizedFixes(unittest.TestCase):
555
+ def _history(self):
556
+ commits = [_commit(f"c{i}", [("src/a.py", 3, 1)], subject="fix: small", date="2026-06-01") for i in range(200)]
557
+ commits.append(_commit("big", [(f"src/f{i}.py", 40, 40) for i in range(30)], subject="fix: everything at once", date="2026-06-02"))
558
+ commits.append(_commit("med", [("src/z.py", 300, 100)], subject="fix: medium", date="2026-06-03"))
559
+ return commits
560
+
561
+ def test_a_fix_over_the_99th_percentile_of_lines_changed_credits_nothing(self):
562
+ rows = {r["entity"]: r for r in maat.fixes(self._history(), now="2026-09-01")}
563
+ self.assertNotIn("src/f1.py", rows, "2,400 lines: over the 99th percentile and over the floor")
564
+ self.assertEqual(rows["src/z.py"]["n-fixes"], 1, "400 lines: under the 500-line floor, whatever the percentile")
565
+ self.assertEqual(rows["src/a.py"]["n-fixes"], 200)
566
+ self.assertEqual([c["hash"] for c in maat.oversized(self._history())], ["big"])
567
+ self.assertEqual(maat.activity(self._history())["oversized_fixes"], 1)
568
+
569
+ def test_fix_commits_is_the_pool_the_backtest_reads(self):
570
+ self.assertEqual([c["hash"] for c in maat.fix_commits(self._history())][-2:], ["c199", "med"])
571
+
572
+
573
+ class Tangled(unittest.TestCase):
574
+ def test_many_files_across_many_directories_under_a_subject_with_several_clauses(self):
575
+ files = [(f"pkg{i % 5}/f{i}.py", 1, 1) for i in range(12)]
576
+ yes = _commit("t1", files, subject="Fix the parser, add a cache and rename the helpers")
577
+ one_thing = _commit("t2", files, subject="Rename the helpers (see foo(a, b))")
578
+ few_dirs = _commit("t3", [(f"pkg/f{i}.py", 1, 1) for i in range(12)], subject="Fix the parser, add a cache and rename the helpers")
579
+ self.assertTrue(maat.is_tangled(yes))
580
+ self.assertFalse(maat.is_tangled(one_thing), "a comma inside brackets is not a clause")
581
+ self.assertFalse(maat.is_tangled(few_dirs))
582
+ self.assertEqual(maat.clauses("Fix a; add b"), 2)
583
+ self.assertEqual(maat.clauses("Fix a & b + c"), 3)
584
+ act = maat.activity([yes, one_thing, few_dirs])
585
+ self.assertEqual(act["tangled_commits"], 1)
586
+ self.assertEqual(act["tangled"], [{"hash": "t1", "date": "2026-01-05", "files": 12, "dirs": 5, "subject": "Fix the parser, add a cache and rename the helpers"}])
587
+
588
+ def test_squash_subjects_are_counted(self):
589
+ commits = [_commit("s1", [("a.py", 1, 0)], subject="Add x (#12)"), _commit("s2", [("a.py", 1, 0)], subject="Add y (#13)"), _commit("r1", [("a.py", 1, 0)], subject="Add z")]
590
+ self.assertEqual(maat.activity(commits)["squash_subjects"], 2)
591
+
592
+
503
593
  class IgnoreRevs(unittest.TestCase):
504
594
  def test_reads_full_shas_past_comments_and_matches_abbreviated_hashes(self):
505
595
  with tempfile.TemporaryDirectory() as d:
@@ -460,6 +460,17 @@ class Report(unittest.TestCase):
460
460
  self.assertNotIn("deps/", coupling)
461
461
  self.assertIn("2 vendored pairs hidden; --full shows them", coupling)
462
462
 
463
+ def test_the_coupling_caption_names_the_merge_regime(self):
464
+ r = sample_report()
465
+ r["size"]["files"].update({"static/tax.html": {"code": 30, "complexity": 0}, "static/treasury.html": {"code": 30, "complexity": 0}})
466
+ r["meta"]["merges"] = 2
467
+ r["activity"]["squash_subjects"] = 300
468
+ coupling = _section_text(rendered(r, [], width=200), "Change coupling")
469
+ self.assertIn("83% of subjects end in (#NNNN) and 2 of 363 commits are merges: squash-merged, so the pairs describe pull requests, not edits", coupling)
470
+ r["activity"]["squash_subjects"] = 3
471
+ coupling = _section_text(rendered(r, [], width=200), "Change coupling")
472
+ self.assertNotIn("squash", coupling)
473
+
463
474
  def test_default_coupling_hides_generated_pairs_and_says_so(self):
464
475
  r = sample_report()
465
476
  r["meta"]["generated"] = ["js/a.bundle.js", "js/b.bundle.js"]
@@ -558,6 +558,7 @@ class CollectMeta(unittest.TestCase):
558
558
  self.assertEqual(meta["name"], os.path.basename(d))
559
559
  self.assertEqual(meta["branch"], "trunk")
560
560
  self.assertEqual(meta["commits"], 3)
561
+ self.assertEqual(meta["merges"], 0, "how many commits have two parents: the merge regime")
561
562
  self.assertEqual(meta["first_date"], "2025-01-02")
562
563
  self.assertEqual(meta["last_date"], "2026-03-04")
563
564
  self.assertEqual(meta["identities"], [{"name": "Ann", "email": "ann@x.com", "commits": 2, "aliases": []},
@@ -79,6 +79,23 @@ class Risks(unittest.TestCase):
79
79
  self.assertNotIn("alongside", " ".join(by["core/util.py"]["reasons"]))
80
80
  self.assertEqual(by["web/index.html"]["minor"], 0, "an output directory without the column reads as none")
81
81
 
82
+ def test_tests_that_never_move_with_a_file_are_a_reason(self):
83
+ r = report()
84
+ r["tests"] = [{"entity": "core/parser.py", "n-sets": 38, "with-tests": 0}, {"entity": "core/util.py", "n-sets": 28, "with-tests": 4},
85
+ {"entity": "web/index.html", "n-sets": 55, "with-tests": 30}]
86
+ by = {x["file"]: x for x in watch.risks(r)}
87
+ self.assertIn("no test changed in its 38 changes", by["core/parser.py"]["reasons"])
88
+ self.assertIn("a test changed in 4 of its 28 changes", by["core/util.py"]["reasons"])
89
+ self.assertNotIn("test", " ".join(by["web/index.html"]["reasons"]), "tests move with most of its changes: nothing to say")
90
+ self.assertEqual(by["core/parser.py"]["tested_share"], 0.0)
91
+ r["tests"] = [{"entity": "core/parser.py", "n-sets": 4, "with-tests": 0}]
92
+ self.assertNotIn("test", " ".join({x["file"]: x for x in watch.risks(r)}["core/parser.py"]["reasons"]), "four changes are too few to judge by")
93
+ self.assertIsNone({x["file"]: x for x in watch.risks(report())}["core/parser.py"]["tested_share"], "an output directory without the table")
94
+ r = report(tests=[{"entity": "core/parser.py", "n-sets": 38, "with-tests": 0}])
95
+ del r["size"]["files"]["tests/test_parser.py"]
96
+ self.assertNotIn("test", " ".join({x["file"]: x for x in watch.risks(r)}["core/parser.py"]["reasons"]),
97
+ "a repository with no test file anywhere has nothing to say about tests moving")
98
+
82
99
  def test_a_nameless_function_is_named_by_its_line_and_a_suspect_span_is_passed_over(self):
83
100
  r = report()
84
101
  r["functions"] = [{"file": "core/parser.py", "function": 'app.post("/api/x", async (req, res) => {', "anonymous": True,
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