gitmole 0.6.6__tar.gz → 0.6.7__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.6 → gitmole-0.6.7}/PKG-INFO +1 -1
  2. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/__init__.py +1 -1
  3. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/filetypes.py +3 -2
  4. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/findings.py +11 -6
  5. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/maat.py +17 -2
  6. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/render.py +2 -1
  7. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/run.py +1 -1
  8. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/watch.py +2 -2
  9. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole.egg-info/PKG-INFO +1 -1
  10. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_filetypes.py +3 -2
  11. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_findings.py +25 -3
  12. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_maat.py +12 -0
  13. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_render.py +11 -0
  14. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_run.py +2 -0
  15. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_watch.py +8 -0
  16. {gitmole-0.6.6 → gitmole-0.6.7}/LICENSE +0 -0
  17. {gitmole-0.6.6 → gitmole-0.6.7}/README.md +0 -0
  18. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/__main__.py +0 -0
  19. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/backtest.py +0 -0
  20. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/banner.py +0 -0
  21. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/blame.py +0 -0
  22. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/clean.py +0 -0
  23. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/cli.py +0 -0
  24. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/coupling.py +0 -0
  25. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/functions.py +0 -0
  26. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/hotspots.py +0 -0
  27. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/identity.py +0 -0
  28. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/knowledge.py +0 -0
  29. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/leaks.py +0 -0
  30. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/load.py +0 -0
  31. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/loss.py +0 -0
  32. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/textfmt.py +0 -0
  33. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole/trend.py +0 -0
  34. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole.egg-info/SOURCES.txt +0 -0
  35. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole.egg-info/dependency_links.txt +0 -0
  36. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole.egg-info/entry_points.txt +0 -0
  37. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole.egg-info/requires.txt +0 -0
  38. {gitmole-0.6.6 → gitmole-0.6.7}/gitmole.egg-info/top_level.txt +0 -0
  39. {gitmole-0.6.6 → gitmole-0.6.7}/pyproject.toml +0 -0
  40. {gitmole-0.6.6 → gitmole-0.6.7}/setup.cfg +0 -0
  41. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_backtest.py +0 -0
  42. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_banner.py +0 -0
  43. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_blame.py +0 -0
  44. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_clean.py +0 -0
  45. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_cli.py +0 -0
  46. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_coupling.py +0 -0
  47. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_functions.py +0 -0
  48. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_golden.py +0 -0
  49. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_hotspots.py +0 -0
  50. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_identity.py +0 -0
  51. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_knowledge.py +0 -0
  52. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_leaks.py +0 -0
  53. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_load.py +0 -0
  54. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_loss.py +0 -0
  55. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_packaging.py +0 -0
  56. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_textfmt.py +0 -0
  57. {gitmole-0.6.6 → gitmole-0.6.7}/tests/test_trend.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.6.6
3
+ Version: 0.6.7
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.6"
3
+ __version__ = "0.6.7"
@@ -76,12 +76,13 @@ def is_sample_path(path: str) -> bool:
76
76
  return bool(_SAMPLE_PATH.search(path))
77
77
 
78
78
 
79
- _VENDOR_PATH = re.compile(r"(^|/)(_?vendor|node_modules|third_?party|external)(/|$)", re.I)
79
+ _VENDOR_PATH = re.compile(r"(^|/)(_?vendor|vendored|node_modules|third_?party|external)(/|$)|^[^/]+/packages/", re.I)
80
80
 
81
81
 
82
82
  def is_vendor_path(path: str) -> bool:
83
83
  """Vendored and third-party trees: somebody else's code, so its complexity and its single
84
- importer are not this repository's risk."""
84
+ importer are not this repository's risk. A `packages/` inside a package (requests/packages/,
85
+ the Python vendoring convention) counts; a monorepo's own `packages/` at the root does not."""
85
86
  return bool(_VENDOR_PATH.search(path))
86
87
 
87
88
 
@@ -47,7 +47,8 @@ def secrets_found(report: dict) -> list:
47
47
  groups = leaks.group(report.get("secrets") or [])
48
48
 
49
49
  def in_source(g):
50
- return any(not (filetypes.is_test_path(f) or filetypes.is_doc_path(f) or filetypes.is_sample_path(f)) 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) or filetypes.is_vendor_path(f))
51
+ for f in g["files"])
51
52
  source = [g for g in groups if in_source(g)]
52
53
  aside = [g for g in groups if not in_source(g)]
53
54
  ignore = "Add the fingerprint of any false positive from secrets.json to .betterleaksignore in the repository."
@@ -56,7 +57,7 @@ def secrets_found(report: dict) -> list:
56
57
  out.append(_f("critical", f"{len(source)} secret(s) in history", _secret_statement(source),
57
58
  f"Rotate them; deleting the file does not remove them from git. {ignore}"))
58
59
  if aside:
59
- out.append(_f("warning", f"{len(aside)} secret(s) only in test, example or documentation files", _secret_statement(aside),
60
+ out.append(_f("warning", f"{len(aside)} secret(s) only in test, example, vendored or documentation files", _secret_statement(aside),
60
61
  f"Confirm they are fixtures or templates, not live keys. {ignore}"))
61
62
  return out
62
63
 
@@ -173,8 +174,10 @@ def sizer_concerns(report: dict) -> list:
173
174
 
174
175
 
175
176
  def hotspot_dominance(report: dict, ratio: float = 2.0, minimum: int = 20) -> list:
176
- """One source file takes most of the churn. Test files are left out: they change with everything."""
177
- revs = sorted((r for r in report.get("revisions") or [] if not filetypes.is_test_path(r["entity"])), key=lambda r: -r["n-revs"])
177
+ """One source file takes most of the churn. Test files are left out: they change with everything.
178
+ So is release plumbing: a version file or a manifest changes on every release by design."""
179
+ revs = sorted((r for r in report.get("revisions") or [] if not (filetypes.is_test_path(r["entity"]) or filetypes.is_release_path(r["entity"]))),
180
+ key=lambda r: -r["n-revs"])
178
181
  if len(revs) < 2 or revs[0]["n-revs"] < minimum or revs[0]["n-revs"] < ratio * revs[1]["n-revs"]:
179
182
  return []
180
183
  top, nxt = revs[0], revs[1]
@@ -231,8 +234,10 @@ def stale_files(report: dict, months: int = 12, share: float = 0.3) -> list:
231
234
 
232
235
 
233
236
  def bug_magnets(report: dict, min_recent: int = 3, warn_at: int = 5) -> list:
234
- """Source files with a run of recent fix commits. Test files are left out: they change with every fix."""
235
- hot = [f for f in report.get("fixes") or [] if f["recent-fixes"] >= min_recent and not filetypes.is_test_path(f["entity"])]
237
+ """Source files with a run of recent fix commits. Test files are left out: they change with every fix.
238
+ So is release plumbing: a manifest touched by every fix release is not where the bug was."""
239
+ hot = [f for f in report.get("fixes") or [] if f["recent-fixes"] >= min_recent
240
+ and not (filetypes.is_test_path(f["entity"]) or filetypes.is_release_path(f["entity"]))]
236
241
  if not hot:
237
242
  return []
238
243
  hot.sort(key=lambda f: (-f["recent-fixes"], -f["n-fixes"], f["entity"]))
@@ -4,7 +4,9 @@
4
4
  Standalone on purpose: gitmole runs it as a pipeline step with
5
5
  `python3 maat.py LOG OUT_DIR [--aliases META_JSON]` and it must not need the
6
6
  package on sys.path. Input is `git log --all --numstat --date=short
7
- --pretty=format:--%h--%ad--%aN --no-renames`.
7
+ --pretty=format:--%h--%ad--%aN -M`: renames are followed, so a moved file
8
+ is one entity under its new path and a pure move adds and deletes nothing.
9
+ Whoever moved a directory to src/ did not write it.
8
10
  """
9
11
  from __future__ import annotations
10
12
 
@@ -40,13 +42,26 @@ def parse_log(text: str, aliases: dict = None, types=None) -> list:
40
42
  commits.append(current)
41
43
  elif line.strip() and current is not None:
42
44
  added, deleted, path = line.split("\t", 2)
43
- path = filetypes.unquote(path)
45
+ path = _renamed_to(filetypes.unquote(path))
44
46
  if not filetypes.matches(path, types):
45
47
  continue
46
48
  current["files"].append((path, int(added) if added.isdigit() else 0, int(deleted) if deleted.isdigit() else 0))
47
49
  return commits
48
50
 
49
51
 
52
+ _BRACED_RENAME = re.compile(r"\{([^{}]*) => ([^{}]*)\}")
53
+
54
+
55
+ def _renamed_to(path: str) -> str:
56
+ """The new path of a rename as `git log -M --numstat` spells it: `{old => new}/rest`,
57
+ `dir/{a => b}` or `old => new` for a whole path. A path without ' => ' is itself."""
58
+ if " => " not in path:
59
+ return path
60
+ if "{" in path:
61
+ return _BRACED_RENAME.sub(lambda m: m.group(2), path).replace("//", "/")
62
+ return path.split(" => ", 1)[1]
63
+
64
+
50
65
  _FIX_CONVENTIONAL = re.compile(r"^(fix|hotfix|bugfix)(\([^)]*\))?!?:", re.I)
51
66
  _FIX_WORDS = re.compile(r"\b(fix|fixes|fixed|fixing|bugfix|hotfix|bug|bugs|regression|crash|crashes)\b", re.I)
52
67
 
@@ -395,7 +395,8 @@ def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
395
395
  scored, hidden_note = _hide_tests(scored, lambda h: h["entity"], full)
396
396
  scored, deleted_note = _hide_deleted(scored, report, full)
397
397
  scored, generated_note = _hide_generated(scored, lambda h: h["entity"], report, full)
398
- hidden_note = _join_hidden(hidden_note, deleted_note, generated_note)
398
+ scored, release_note = _hide_rows(scored, lambda h: h["entity"], full, filetypes.is_release_path, "release file")
399
+ hidden_note = _join_hidden(hidden_note, deleted_note, generated_note, release_note)
399
400
  title = "Hotspots (score = revisions × lines of code)" if full is True else "Hotspots"
400
401
  limit = _limit("Hotspots", full)
401
402
  series = (report.get("trend") or {}).get("files") or {}
@@ -172,7 +172,7 @@ def plan(repo_dir: str, out_dir: str, branch: str = "HEAD", age: bool = True, pl
172
172
  {"name": "scc", "argv": ["scc", "--by-file", "--format", "json"], "stdout": o("size.json"), "deps": []},
173
173
  {"name": "git-sizer", "argv": ["git-sizer", "--verbose"], "stdout": o("repo-health.txt"), "deps": []},
174
174
  {"name": "betterleaks", "argv": [sys.executable, LEAKS_SCRIPT, o("secrets.json")], "stdout": None, "deps": []}, # hashes the values before anything is written
175
- {"name": "git-log", "argv": [*filetypes.GIT, "log", "--all", "--use-mailmap", "--numstat", "--date=iso-strict", "--pretty=format:--%h--%ad--%aN--%s", "--no-renames"], "stdout": log, "deps": []},
175
+ {"name": "git-log", "argv": [*filetypes.GIT, "log", "--all", "--use-mailmap", "--numstat", "--date=iso-strict", "--pretty=format:--%h--%ad--%aN--%s", "-M"], "stdout": log, "deps": []}, # -M: a move is not an edit
176
176
  {"name": "change analysis", "argv": [sys.executable, MAAT_SCRIPT, log, out_dir, *type_args, *(["--now", now] if now else []), *(["--since", since] if since else []), "--aliases", o("meta.json")], "stdout": None, "deps": ["git-log"]},
177
177
  ]
178
178
  if lizard:
@@ -66,8 +66,8 @@ def risks(report: dict, min_revs: int = 2) -> list:
66
66
 
67
67
  rows = []
68
68
  for h in hotspots.ranked(report):
69
- if h["code"] is None or h["revs"] < min_revs or filetypes.is_test_path(h["entity"]):
70
- continue
69
+ if h["code"] is None or h["revs"] < min_revs or filetypes.is_test_path(h["entity"]) or filetypes.is_release_path(h["entity"]):
70
+ continue # a version file or a manifest changes on every release, not where the next bug lands
71
71
  fx = fixes.get(h["entity"], {})
72
72
  own = owners.get(h["entity"]) or Counter()
73
73
  owner, owner_lines = (own.most_common(1)[0] if own else (None, 0))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.6.6
3
+ Version: 0.6.7
4
4
  Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/antvinni/gitmole
@@ -133,9 +133,10 @@ class TestPaths(unittest.TestCase):
133
133
 
134
134
  def test_vendored_trees(self):
135
135
  for path in ("vendor/github.com/x/y.go", "web/node_modules/a/index.js", "third_party/z/a.c", "thirdparty/a.c", "_vendor/a.py",
136
- "external/lib/a.cpp"):
136
+ "external/lib/a.cpp", "requests/packages/urllib3/a.py", "pip/_vendor/six.py", "botocore/vendored/requests/a.py"):
137
137
  self.assertTrue(filetypes.is_vendor_path(path), path)
138
- for path in ("vendors.py", "src/vendoring/a.py", "node/a.js", "externals.txt", "app/main.go"):
138
+ for path in ("vendors.py", "src/vendoring/a.py", "node/a.js", "externals.txt", "app/main.go",
139
+ "packages/runtime-core/src/renderer.ts", "packages-private/x.ts"): # a monorepo's own packages/ at the root
139
140
  self.assertFalse(filetypes.is_vendor_path(path), path)
140
141
 
141
142
 
@@ -47,7 +47,7 @@ class SecretsFound(unittest.TestCase):
47
47
  self.assertIn("1 distinct value in 2 places: generic-api-key in app/settings.py (c1, c2)", crit["detail"])
48
48
  self.assertIn("Rotate", crit["advice"])
49
49
  self.assertIn(".betterleaksignore", crit["advice"])
50
- self.assertEqual(warn["title"], "2 secret(s) only in test, example or documentation files")
50
+ self.assertEqual(warn["title"], "2 secret(s) only in test, example, vendored or documentation files")
51
51
  self.assertIn("2 distinct values in 3 places", warn["detail"])
52
52
  self.assertIn("tests/data/a.html and 1 other file", warn["detail"])
53
53
  self.assertIn(".betterleaksignore", warn["advice"])
@@ -62,15 +62,22 @@ class SecretsFound(unittest.TestCase):
62
62
  self.row("h3", "pkg/testdata/creds.yaml", "c3")])
63
63
  f = findings.secrets_found(r)
64
64
  self.assertEqual([x["severity"] for x in f], ["warning"])
65
- self.assertEqual(f[0]["title"], "3 secret(s) only in test, example or documentation files")
65
+ self.assertEqual(f[0]["title"], "3 secret(s) only in test, example, vendored or documentation files")
66
66
  r = report(secrets=[self.row("h1", "examples/app.py", "c1"), self.row("h1", "app/config.py", "c2")])
67
67
  self.assertEqual([x["severity"] for x in findings.secrets_found(r)], ["critical"], "the same value in source is a leak")
68
68
 
69
+ def test_a_value_only_in_vendored_code_is_a_warning(self):
70
+ # oauthlib's RFC test vectors inside requests/packages/: upstream's specimen, not this repository's credential
71
+ r = report(secrets=[self.row("h1", "requests/packages/oauthlib/oauth1/rfc5849/parameters.py", "9576518")])
72
+ f = findings.secrets_found(r)
73
+ self.assertEqual([x["severity"] for x in f], ["warning"])
74
+ self.assertIn("vendored", f[0]["title"])
75
+
69
76
  def test_a_value_only_in_documentation_is_a_warning_that_says_template(self):
70
77
  r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508")])
71
78
  f = findings.secrets_found(r)
72
79
  self.assertEqual([x["severity"] for x in f], ["warning"])
73
- self.assertEqual(f[0]["title"], "1 secret(s) only in test, example or documentation files")
80
+ self.assertEqual(f[0]["title"], "1 secret(s) only in test, example, vendored or documentation files")
74
81
  self.assertIn("fixtures or templates", f[0]["advice"])
75
82
  r = report(secrets=[self.row("h1", "docs/GA4-API-INTEGRATION.md", "e8c0508"), self.row("h1", "app/config.py", "c2")])
76
83
  self.assertEqual([x["severity"] for x in findings.secrets_found(r)], ["critical"], "the same value in source is a leak")
@@ -283,6 +290,21 @@ class TightCoupling(unittest.TestCase):
283
290
  self.assertEqual(findings.tight_coupling(report()), [])
284
291
 
285
292
 
293
+ class ReleasePlumbing(unittest.TestCase):
294
+ def test_a_version_file_or_manifest_does_not_dominate_the_churn(self):
295
+ revs = [{"entity": "setup.py", "n-revs": 184}, {"entity": "requests/models.py", "n-revs": 60}, {"entity": "requests/api.py", "n-revs": 20}]
296
+ f = findings.hotspot_dominance(report(revisions=revs))
297
+ self.assertIn("requests/models.py changed 60 times", f[0]["detail"])
298
+ self.assertNotIn("setup.py", f[0]["detail"])
299
+
300
+ def test_a_manifest_is_not_a_bug_magnet(self):
301
+ fixes = [{"entity": "package.json", "n-fixes": 20, "last-fix": "2026-09-01", "recent-fixes": 6},
302
+ {"entity": "lib/reply.js", "n-fixes": 10, "last-fix": "2026-09-01", "recent-fixes": 4}]
303
+ f = findings.bug_magnets(report(fixes=fixes))
304
+ self.assertIn("lib/reply.js", f[0]["detail"])
305
+ self.assertNotIn("package.json", f[0]["detail"])
306
+
307
+
286
308
  class StaleFiles(unittest.TestCase):
287
309
  def test_info_when_a_third_untouched_for_a_year(self):
288
310
  age = [{"entity": f"f{i}", "age-months": 12} for i in range(4)] + [{"entity": "g", "age-months": 0} for _ in range(6)]
@@ -48,6 +48,18 @@ class ParseLog(unittest.TestCase):
48
48
  self.assertEqual(commits[0]["files"], [("src/a.py", 3, 1), ("src/b.py", 2, 0)])
49
49
  self.assertEqual(commits[1]["files"][2], ("img/logo.png", 0, 0))
50
50
 
51
+ def test_renames_are_followed_to_the_new_path_and_a_pure_move_adds_no_lines(self):
52
+ # `git log -M --numstat` spells a rename three ways; the mover is not the owner of what moved
53
+ log = ("--d63e94f5--2023-08-13T10:00:00+00:00--Nate--Move to src layout\n"
54
+ "0\t0\t{requests => src/requests}/__init__.py\n"
55
+ "4\t7\tSECURITY.md => .github/SECURITY.md\n"
56
+ "0\t0\tCODE_OF_CONDUCT.md => .github/CODE_OF_CONDUCT.md\n"
57
+ "2\t0\tsrc/requests/{models.py => models_v2.py}\n"
58
+ "1\t1\tMakefile\n")
59
+ commits = maat.parse_log(log, types=None)
60
+ self.assertEqual(commits[0]["files"], [("src/requests/__init__.py", 0, 0), (".github/SECURITY.md", 4, 7),
61
+ (".github/CODE_OF_CONDUCT.md", 0, 0), ("src/requests/models_v2.py", 2, 0), ("Makefile", 1, 1)])
62
+
51
63
  def test_subjects_with_exotic_line_break_characters_do_not_split_the_log(self):
52
64
  # U+2028 and form feed are line breaks to str.splitlines but not to git
53
65
  text = "--x--2026-05-04T10:00:00+00:00--Ann--Fix\u2028broken\x0cthing\n1\t0\tf.py\n"
@@ -334,6 +334,17 @@ class Report(unittest.TestCase):
334
334
  full = rendered(r, [], width=200, full=True)
335
335
  self.assertIn("validate10", full)
336
336
 
337
+ def test_default_hotspots_hide_release_plumbing_and_say_so(self):
338
+ r = sample_report()
339
+ r["size"]["files"].update({"setup.py": {"code": 6, "complexity": 0}, "version.go": {"code": 2, "complexity": 0}})
340
+ r["revisions"] += [{"entity": "setup.py", "n-revs": 184}, {"entity": "version.go", "n-revs": 29}]
341
+ hot = rendered(r, [], width=200)
342
+ hot = hot[hot.index("◆ Hotspots"):hot.index("Change coupling")]
343
+ self.assertNotIn("setup.py", hot)
344
+ self.assertIn("2 release files hidden; --full shows them", hot)
345
+ full = rendered(r, [], width=200, full=True)
346
+ self.assertIn("setup.py", full[full.index("◆ Hotspots"):])
347
+
337
348
  def test_default_coupling_hides_release_plumbing_pairs_and_says_so(self):
338
349
  r = sample_report()
339
350
  for f in ("lib/version.rb", "contrib/version.rb", "Gemfile", "Gemfile.lock"):
@@ -192,6 +192,8 @@ class Plan(unittest.TestCase):
192
192
  self.assertEqual(by["scc"]["stdout"], "/o/size.json")
193
193
  self.assertIn("--by-file", by["scc"]["argv"])
194
194
  self.assertIn("--use-mailmap", by["git-log"]["argv"])
195
+ self.assertIn("-M", by["git-log"]["argv"], "renames are followed so a move to src/ credits nobody with the moved lines")
196
+ self.assertNotIn("--no-renames", by["git-log"]["argv"])
195
197
  self.assertEqual(by["git-log"]["argv"][:4], ["git", "-c", "core.quotePath=false", "log"], "non-ASCII paths must not be octal-escaped and quoted")
196
198
 
197
199
  def test_function_metrics_step_is_optional_and_runs_the_bundled_script(self):
@@ -53,6 +53,14 @@ class Risks(unittest.TestCase):
53
53
  self.assertNotIn("core/gone.py", files, "no longer in the tree")
54
54
  self.assertNotIn("core/once.py", files, "changed once")
55
55
 
56
+ def test_release_plumbing_is_not_on_the_list(self):
57
+ r = report()
58
+ r["size"]["files"].update({"setup.py": {"code": 6, "complexity": 0}, "version.go": {"code": 2, "complexity": 0}, "Makefile": {"code": 21, "complexity": 0}})
59
+ r["revisions"] = [{"entity": "setup.py", "n-revs": 184}, {"entity": "version.go", "n-revs": 29}, {"entity": "Makefile", "n-revs": 131},
60
+ {"entity": "core/parser.py", "n-revs": 40}]
61
+ files = sorted(x["file"] for x in watch.risks(r))
62
+ self.assertEqual(files, ["Makefile", "core/parser.py"], "a version file or a manifest changes on every release, not where the next bug lands")
63
+
56
64
  def test_test_companions_and_weak_pairs_are_not_reasons(self):
57
65
  top = watch.risks(report())[0]
58
66
  coupling = [r for r in top["reasons"] if r.startswith("changes with")][0]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes