gitmole 0.16.0__tar.gz → 0.17.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.
- {gitmole-0.16.0 → gitmole-0.17.0}/PKG-INFO +1 -1
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/__init__.py +1 -1
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/cli.py +4 -1
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/duplicates.py +44 -2
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/findings.py +68 -1
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/load.py +6 -2
- gitmole-0.17.0/gitmole/provenance.py +241 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/render.py +29 -2
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/run.py +5 -3
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/PKG-INFO +1 -1
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/SOURCES.txt +2 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_duplicates.py +13 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_findings.py +40 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_load.py +6 -0
- gitmole-0.17.0/tests/test_provenance.py +128 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_render.py +26 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_run.py +9 -1
- {gitmole-0.16.0 → gitmole-0.17.0}/LICENSE +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/README.md +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/__main__.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/backtest.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/banner.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/blame.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/classify.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/clean.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/compare.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/coupling.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/deps.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/evaluate.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/filetypes.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/functions.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/hook.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/hotspots.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/hygiene.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/identity.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/knowledge.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/leaks.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/loss.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/maat.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/sarif.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/signing.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/structure.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/szz.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/textfmt.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/trend.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/watch.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/pyproject.toml +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/setup.cfg +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_backtest.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_banner.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_blame.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_classify.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_clean.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_cli.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_compare.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_coupling.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_deps.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_evaluate.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_filetypes.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_functions.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_golden.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_hook.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_hotspots.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_hygiene.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_identity.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_knowledge.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_leaks.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_loss.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_maat.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_packaging.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_render_examples.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_sarif.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_signing.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_structure.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_szz.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_textfmt.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_trend.py +0 -0
- {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.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
|
|
@@ -369,6 +369,9 @@ def _meta_for_run(repo_dir: str, args, estimate, age_ok: bool, plots_ok: bool, p
|
|
|
369
369
|
from . import maat as _maat
|
|
370
370
|
cut = _maat.months_before(meta["last_date"], 6) if meta["last_date"] else None
|
|
371
371
|
first = meta.get("first_date_all") or meta["first_date"] # the backtest reads the whole history, window or not
|
|
372
|
+
year = _maat.months_before(meta["last_date"], 12) if meta["last_date"] else None
|
|
373
|
+
if year and first and first <= year:
|
|
374
|
+
meta["duplicates"]["then"] = year # the rate a year back, when the history reaches it
|
|
372
375
|
if cut and first and first <= _maat.months_before(cut, 6):
|
|
373
376
|
meta["backtest"] = {"status": "planned", "until": cut}
|
|
374
377
|
else:
|
|
@@ -427,7 +430,7 @@ def _analyse(repo_dir: str, out_dir: str, args, ui: Console, planner, estimator)
|
|
|
427
430
|
run.clear_outputs(out_dir)
|
|
428
431
|
steps = planner(repo_dir, out_dir, branch=meta["branch"], age=age_ok, plots=plots_ok, ignore=ignore, types=types_spec, now=args.now,
|
|
429
432
|
since=args.since_date, lizard=lizard_ok, duplicates=duplicates_ok, backtest=cut, ignore_revs=run.ignore_revs_files(repo_dir),
|
|
430
|
-
structure=getattr(args, "structure", False))
|
|
433
|
+
structure=getattr(args, "structure", False), duplicates_then=meta["duplicates"].get("then"))
|
|
431
434
|
run.save_meta(meta, out_dir)
|
|
432
435
|
results = _execute(steps, log_path, repo_dir, args.workers, ui, timeout=args.timeout)
|
|
433
436
|
if _control.cancelled.is_set():
|
|
@@ -6,8 +6,10 @@ jscpd walks the working tree and reports every pair of matching fragments. This
|
|
|
6
6
|
whose two sides are both tracked files inside the analysed types, folds the pairs of one fragment into
|
|
7
7
|
a block with all its places, measures the duplicated share over the kept files, and writes
|
|
8
8
|
duplicates.json. jscpd's own report quotes every fragment, so it is written to a temporary directory
|
|
9
|
-
under OUT and removed before this returns: no source text lands in the output directory.
|
|
10
|
-
|
|
9
|
+
under OUT and removed before this returns: no source text lands in the output directory. With --then
|
|
10
|
+
DATE the tree at the last commit before that date is exported under OUT, measured the same way and
|
|
11
|
+
removed, so the rate has a direction: GitClear's longitudinal data shows duplication is where the
|
|
12
|
+
change is, and one number without the year before it says little. Standalone, like maat.py and blame.py.
|
|
11
13
|
"""
|
|
12
14
|
from __future__ import annotations
|
|
13
15
|
|
|
@@ -118,6 +120,40 @@ def run_jscpd(repo: str, out: str, procs: int, ignore=()) -> tuple:
|
|
|
118
120
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
119
121
|
|
|
120
122
|
|
|
123
|
+
def rev_before(repo: str, date: str):
|
|
124
|
+
out = subprocess.run(["git", "rev-list", "-1", f"--before={date}T00:00:00", "HEAD"], cwd=repo, capture_output=True, text=True)
|
|
125
|
+
return out.stdout.strip() or None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def files_at(repo: str, rev: str, ignore=(), types_spec: str = None) -> list:
|
|
129
|
+
"""The tracked text files of the tree at `rev`, in the analysed types, as select_files lists HEAD's."""
|
|
130
|
+
import fnmatch
|
|
131
|
+
proc = subprocess.run([*filetypes.GIT, "grep", "-I", "--name-only", "-z", "-e", "", rev], cwd=repo, capture_output=True)
|
|
132
|
+
types = filetypes.parse(types_spec)
|
|
133
|
+
paths = sorted(p.decode("utf-8", "surrogateescape").split(":", 1)[1] for p in proc.stdout.split(b"\0") if p)
|
|
134
|
+
return [f for f in paths if filetypes.matches(f, types) and not any(fnmatch.fnmatch(f, g) for g in ignore)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def rate_at(repo: str, out: str, rev: str, procs: int, ignore=(), types_spec: str = None):
|
|
138
|
+
"""The duplicated share of the tree at `rev`: the tree exported through a temporary index under
|
|
139
|
+
`out` (as the backtest exports its cut-off), jscpd over it, the same fold and rate as HEAD's."""
|
|
140
|
+
tmp = tempfile.mkdtemp(prefix=".dup-then-", dir=out)
|
|
141
|
+
try:
|
|
142
|
+
tree = os.path.join(tmp, "tree")
|
|
143
|
+
os.makedirs(tree)
|
|
144
|
+
env = dict(os.environ, GIT_INDEX_FILE=os.path.join(tmp, "index"))
|
|
145
|
+
subprocess.run(["git", "read-tree", rev], cwd=repo, env=env, check=True, capture_output=True)
|
|
146
|
+
subprocess.run(["git", "checkout-index", "-a", f"--prefix={tree}/"], cwd=repo, env=env, check=True, capture_output=True)
|
|
147
|
+
files = files_at(repo, rev, ignore, types_spec)
|
|
148
|
+
rc, report = run_jscpd(tree, out, procs, ignore)
|
|
149
|
+
if rc != 0:
|
|
150
|
+
return None
|
|
151
|
+
blocks = fold(report.get("duplicates") or [], set(files))
|
|
152
|
+
return {"files": len(files), "rate": rate(blocks, tree, files)}
|
|
153
|
+
finally:
|
|
154
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
155
|
+
|
|
156
|
+
|
|
121
157
|
def write(result: dict, target: str) -> None:
|
|
122
158
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(target)), prefix=".duplicates-", suffix=".json")
|
|
123
159
|
try:
|
|
@@ -136,6 +172,7 @@ def main(argv=None) -> int:
|
|
|
136
172
|
p.add_argument("--procs", type=int, default=1)
|
|
137
173
|
p.add_argument("--ignore", action="append", default=[])
|
|
138
174
|
p.add_argument("--types", default=None, help="file types spec as for gitmole --file-types")
|
|
175
|
+
p.add_argument("--then", metavar="YYYY-MM-DD", help="also measure the tree at the last commit before this date, for the direction")
|
|
139
176
|
args = p.parse_args(argv)
|
|
140
177
|
repo, out = os.path.abspath(args.repo), os.path.abspath(args.out)
|
|
141
178
|
files = select_files(repo, args.ignore, args.types)
|
|
@@ -145,6 +182,11 @@ def main(argv=None) -> int:
|
|
|
145
182
|
blocks = fold(report.get("duplicates") or [], set(files))
|
|
146
183
|
result = {"tool": "jscpd", "files": len(files), "clones": sum(len(b["places"]) - 1 for b in blocks),
|
|
147
184
|
"rate": rate(blocks, repo, files), "blocks": blocks[:BLOCKS_KEPT]}
|
|
185
|
+
if args.then:
|
|
186
|
+
rev = rev_before(repo, args.then)
|
|
187
|
+
measured = rate_at(repo, out, rev, args.procs, args.ignore, args.types) if rev else None
|
|
188
|
+
if measured:
|
|
189
|
+
result["then"] = {"date": args.then, "rev": rev[:12], **measured}
|
|
148
190
|
write(result, os.path.join(out, "duplicates.json"))
|
|
149
191
|
return 0
|
|
150
192
|
|
|
@@ -1022,9 +1022,76 @@ def unreferenced_files(report: dict) -> list:
|
|
|
1022
1022
|
rule={"id": "unreferenced_files", "ref": "Romano et al., TSE 2020"}, evidence={"count": n, "files": paths[:10]})]
|
|
1023
1023
|
|
|
1024
1024
|
|
|
1025
|
+
def _agents(report: dict) -> dict:
|
|
1026
|
+
return ((report.get("provenance") or {}).get("agents")) or {}
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def agent_approval_disabled(report: dict) -> list:
|
|
1030
|
+
"""A committed agent configuration that turns approval prompts off: every clone that picks the
|
|
1031
|
+
settings up runs the agent's tools without asking."""
|
|
1032
|
+
rows = _agents(report).get("approval_disabled") or []
|
|
1033
|
+
if not rows:
|
|
1034
|
+
return []
|
|
1035
|
+
listed = "; ".join(f"{r['file']} sets {r['setting']}" for r in rows)
|
|
1036
|
+
return [_f("warning", "Agent approval prompts turned off in the repository", f"{listed}. Anyone who opens the clone with that agent runs its tools unasked.",
|
|
1037
|
+
"Move the setting to your personal settings file, which is not committed, and keep the shared one to what everyone should get.",
|
|
1038
|
+
rule={"id": "agent_approval_disabled", "by": "tracked agent settings"}, evidence={"settings": rows})]
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def agent_local_settings(report: dict) -> list:
|
|
1042
|
+
rows = _agents(report).get("local_settings") or []
|
|
1043
|
+
if not rows:
|
|
1044
|
+
return []
|
|
1045
|
+
return [_f("warning", "Personal agent settings tracked", f"{_files_list(rows)} {'is' if len(rows) == 1 else 'are'} tracked; the file is meant for one machine and to stay out of git.",
|
|
1046
|
+
f"git rm --cached {rows[0]} and add it to .gitignore.",
|
|
1047
|
+
rule={"id": "agent_local_settings", "by": "path convention"}, evidence={"files": rows})]
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def mcp_literal_env(report: dict) -> list:
|
|
1051
|
+
"""An MCP server declaration whose environment holds a literal value long enough to be a
|
|
1052
|
+
credential rather than a ${VAR} reference. The key is named, the value never."""
|
|
1053
|
+
rows = [(m["file"], x) for m in _agents(report).get("mcp") or [] for x in m.get("literal_env") or []]
|
|
1054
|
+
if not rows:
|
|
1055
|
+
return []
|
|
1056
|
+
listed = "; ".join(f"{x['server']} sets {x['key']} in {f} to a literal value" for f, x in rows[:5])
|
|
1057
|
+
f0, x0 = rows[0]
|
|
1058
|
+
return [_f("warning", "Literal values in MCP server declarations", f"{listed}. A committed declaration shares whatever it holds.",
|
|
1059
|
+
f"Replace the value of {x0['key']} with ${{{x0['key']}}} and set it in the environment; rotate it if it was a live credential.",
|
|
1060
|
+
rule={"id": "mcp_literal_env", "min_length": 16, "placeholders": "left out"},
|
|
1061
|
+
evidence={"entries": [{"file": f, "server": x["server"], "key": x["key"]} for f, x in rows[:10]]})]
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def agent_instructions_drift(report: dict, min_months: int = 6, min_commits: int = 100) -> list:
|
|
1065
|
+
"""Agent instruction files (AGENTS.md and the like) far behind the code they describe."""
|
|
1066
|
+
last = (report.get("meta") or {}).get("last_date") or ""
|
|
1067
|
+
stale = [r for r in _agents(report).get("instructions") or []
|
|
1068
|
+
if last and _months_apart(r["last"], last) >= min_months and r["commits_behind"] >= min_commits]
|
|
1069
|
+
if not stale:
|
|
1070
|
+
return []
|
|
1071
|
+
listed = "; ".join(f"{r['file']} last changed on {r['last']}, {_months_apart(r['last'], last)} months and {r['commits_behind']:,} commits before the last commit" for r in stale)
|
|
1072
|
+
return [_f("info", "Agent instructions behind the code", f"{listed}.",
|
|
1073
|
+
f"Read {stale[0]['file']} against the tree and update what moved; an agent follows it literally.",
|
|
1074
|
+
rule={"id": "agent_instructions_drift", "min_months": min_months, "min_commits": min_commits},
|
|
1075
|
+
evidence={"files": stale})]
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def signoff_by_co_author(report: dict, min_commits: int = 2) -> list:
|
|
1079
|
+
"""An identity that signs off commits it co-authors but never authors one: the Linux kernel's policy
|
|
1080
|
+
on coding assistants forbids an agent to add Signed-off-by, since the DCO is a human's statement."""
|
|
1081
|
+
rows = [r for r in ((report.get("provenance") or {}).get("trailers") or {}).get("signoff_by_co_author") or [] if r["commits"] >= min_commits]
|
|
1082
|
+
if not rows:
|
|
1083
|
+
return []
|
|
1084
|
+
listed = "; ".join(f"{r['name']} <{r['email']}> signs off {_plural(r['commits'], 'commit')} but never authors one" for r in rows[:3])
|
|
1085
|
+
return [_f("info", "Sign-offs by identities that only co-author", f"{listed}.",
|
|
1086
|
+
"A Signed-off-by line certifies the Developer Certificate of Origin; have a person who authors commits add it.",
|
|
1087
|
+
rule={"id": "signoff_by_co_author", "min_commits": min_commits, "ref": "Linux kernel, Documentation/process/coding-assistants.rst"},
|
|
1088
|
+
evidence={"identities": rows[:10]})]
|
|
1089
|
+
|
|
1090
|
+
|
|
1025
1091
|
RULES = [dormant, secrets_found, credential_files, vulnerable_dependencies, placeholder_identity, bus_factor, sizer_concerns, hotspot_dominance, bug_magnets,
|
|
1026
1092
|
minor_contributors, reverts, brain_methods, complexity_growth, tight_coupling, duplication, stale_files, knowledge_islands, knowledge_loss,
|
|
1027
|
-
sweeping_commits, tangled_commits, hygiene_findings, debt_in_hotspots, deep_nesting, hidden_coupling, unreferenced_files
|
|
1093
|
+
sweeping_commits, tangled_commits, hygiene_findings, debt_in_hotspots, deep_nesting, hidden_coupling, unreferenced_files,
|
|
1094
|
+
agent_approval_disabled, agent_local_settings, mcp_literal_env, agent_instructions_drift, signoff_by_co_author]
|
|
1028
1095
|
|
|
1029
1096
|
|
|
1030
1097
|
def evaluate(report: dict) -> list:
|
|
@@ -197,7 +197,10 @@ def parse_duplicates_json(data) -> dict | None:
|
|
|
197
197
|
blocks = [{"lines": _num(b.get("lines")), "places": sorted(tuple(p[:3]) for p in b.get("places") or [] if len(p) >= 3)}
|
|
198
198
|
for b in data.get("blocks") or []]
|
|
199
199
|
rate = data.get("rate")
|
|
200
|
-
|
|
200
|
+
out = {"rate": float(rate) if rate is not None else None, "blocks": blocks, "files": _num(data.get("files"))}
|
|
201
|
+
if isinstance(data.get("then"), dict):
|
|
202
|
+
out["then"] = data["then"] # the rate at the last commit a year before: the direction
|
|
203
|
+
return out
|
|
201
204
|
|
|
202
205
|
|
|
203
206
|
def parse_duplicates(text: str) -> dict:
|
|
@@ -331,6 +334,7 @@ def load_report(out_dir: str, nested: bool = True) -> dict:
|
|
|
331
334
|
"signing": _read_json(out_dir, "signing.json", {}) or {}, # commit signing coverage; {} before the step or after a killed one
|
|
332
335
|
"hygiene": _read_json(out_dir, "hygiene.json", {}) or {}, # the hygiene checks (hygiene.py); {} before 0.15
|
|
333
336
|
"unreachable": _read_json(out_dir, "unreachable.json", {}) or {},
|
|
334
|
-
"structure": _read_json(out_dir, "structure.json", {}) or {},
|
|
337
|
+
"structure": _read_json(out_dir, "structure.json", {}) or {},
|
|
338
|
+
"provenance": _read_json(out_dir, "provenance.json", {}) or {}, # trailers, cohorts, commit shape, agent files; {} before 0.17 # tree-sitter metrics (structure.py); {} without gitmole[structure] # what the secrets step found outside reachable history
|
|
335
339
|
"backtest": _nested(out_dir) if nested else None,
|
|
336
340
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Provenance: what the history declares about how its commits were made, and the agent configuration
|
|
2
|
+
the tree declares, read and never inferred.
|
|
3
|
+
|
|
4
|
+
Runs as a pipeline step, `python -m gitmole.provenance OUT_DIR`, from inside the repository, and
|
|
5
|
+
writes provenance.json:
|
|
6
|
+
|
|
7
|
+
- trailers: every trailer key and how many commits carry it; the co-authors who never author a commit
|
|
8
|
+
here (a structural fact about the repository, no list of products); sign-offs by such identities,
|
|
9
|
+
which the Linux kernel's policy on coding assistants forbids an agent to add.
|
|
10
|
+
- cohort: commits with an `Assisted-by` trailer or a never-authoring co-author, against the rest: how
|
|
11
|
+
many were reverted (by git's own `Revert "subject"`), how many are fixes, and how many had a file
|
|
12
|
+
changed again by another commit within two weeks. This repository against itself, with the share
|
|
13
|
+
of commits the cohort covers beside it; no prior from elsewhere, since the best-controlled study
|
|
14
|
+
found the spread between agents larger than the pooled difference.
|
|
15
|
+
- shape: neutral descriptors (commits landing in bursts, conventional-commit subjects, how many hours
|
|
16
|
+
of the day commits come in). Every one has a benign cause, and none is labelled.
|
|
17
|
+
- agents: the agent instruction files by path convention (AGENTS.md, CLAUDE.md, GEMINI.md,
|
|
18
|
+
.github/copilot-instructions.md) and how far behind HEAD each is, the hook files that declare
|
|
19
|
+
guardrails, tracked personal settings, settings that turn approval prompts off, and MCP server
|
|
20
|
+
declarations whose environment carries literal values rather than references. Values are never
|
|
21
|
+
written."""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import bisect
|
|
25
|
+
import datetime as dt
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from collections import Counter
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from . import filetypes, leaks
|
|
35
|
+
except ImportError: # run as a script: the package directory is sys.path[0]
|
|
36
|
+
import filetypes
|
|
37
|
+
import leaks
|
|
38
|
+
|
|
39
|
+
SEP, END = "\x1f", "\x1e"
|
|
40
|
+
# a trailer key is hyphenated by convention (Co-authored-by, Signed-off-by, Change-Id); git's parser also
|
|
41
|
+
# accepts a URL or a line of prose that happens to end the message, and those are not trailers
|
|
42
|
+
_TRAILER_LINE = re.compile(r"^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+):\s*(?!//)(.+)$")
|
|
43
|
+
_IDENT = re.compile(r"^\s*(?P<name>[^<]*?)\s*<(?P<email>[^>]*)>\s*$")
|
|
44
|
+
_CONVENTIONAL = re.compile(r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]*\))?!?: \S")
|
|
45
|
+
BURST_SIZE, BURST_SECONDS = 5, 600
|
|
46
|
+
RETOUCH_DAYS = 14
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_commits(repo: str) -> list:
|
|
50
|
+
"""[{hash, time, author, email, subject, trailers: [(key, value)], files}] of HEAD's history, oldest
|
|
51
|
+
first, through one git log: trailers by git's own parser, files from --name-only."""
|
|
52
|
+
fmt = f"{END}%H{SEP}%at{SEP}%aN{SEP}%aE{SEP}%s{SEP}%(trailers:unfold,only){SEP}"
|
|
53
|
+
out = subprocess.run([*filetypes.GIT, "log", "HEAD", "--use-mailmap", "--name-only", f"--format={fmt}"], cwd=repo,
|
|
54
|
+
capture_output=True, check=True).stdout.decode("utf-8", "replace")
|
|
55
|
+
commits = []
|
|
56
|
+
for chunk in out.split(END)[1:]:
|
|
57
|
+
parts = chunk.split(SEP)
|
|
58
|
+
if len(parts) < 7:
|
|
59
|
+
continue
|
|
60
|
+
h, at, name, email, subject, trailer_text, files = parts[:7]
|
|
61
|
+
trailers = [(m.group(1), m.group(2).strip()) for m in (_TRAILER_LINE.match(l) for l in trailer_text.split("\n")) if m]
|
|
62
|
+
commits.append({"hash": h, "time": int(at), "author": name, "email": email.lower(), "subject": subject,
|
|
63
|
+
"trailers": trailers, "files": [f for f in files.split("\n") if f.strip()]})
|
|
64
|
+
commits.reverse()
|
|
65
|
+
return commits
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ident(value: str):
|
|
69
|
+
m = _IDENT.match(value)
|
|
70
|
+
return (m.group("name"), m.group("email").lower()) if m else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def trailers(commits: list) -> dict:
|
|
74
|
+
keys = Counter()
|
|
75
|
+
authors = {c["email"] for c in commits} | {c["author"] for c in commits}
|
|
76
|
+
co, signed = Counter(), Counter()
|
|
77
|
+
names = {}
|
|
78
|
+
for c in commits:
|
|
79
|
+
for k in {k for k, _ in c["trailers"]}:
|
|
80
|
+
keys[k] += 1
|
|
81
|
+
for k, v in c["trailers"]:
|
|
82
|
+
ident = _ident(v)
|
|
83
|
+
if not ident:
|
|
84
|
+
continue
|
|
85
|
+
names.setdefault(ident[1], ident[0])
|
|
86
|
+
if k.lower() == "co-authored-by":
|
|
87
|
+
co[ident[1]] += 1
|
|
88
|
+
elif k.lower() == "signed-off-by":
|
|
89
|
+
signed[ident[1]] += 1
|
|
90
|
+
never = {e for e in co if e not in authors and names[e] not in authors}
|
|
91
|
+
listing = sorted(({"name": names[e], "email": e, "commits": co[e]} for e in never), key=lambda x: (-x["commits"], x["name"]))
|
|
92
|
+
signoff = sorted(({"name": names[e], "email": e, "commits": signed[e]} for e in never if signed.get(e)), key=lambda x: (-x["commits"], x["name"]))
|
|
93
|
+
return {"commits": len(commits), "keys": dict(sorted(keys.items(), key=lambda kv: (-kv[1], kv[0]))[:25]),
|
|
94
|
+
"with_any": sum(1 for c in commits if c["trailers"]), "never_author": listing[:50], "signoff_by_co_author": signoff[:50]}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cohort(commits: list, inventory: dict) -> dict:
|
|
98
|
+
"""The commits an `Assisted-by` trailer or a never-authoring co-author marks, against the rest."""
|
|
99
|
+
marked_emails = {x["email"] for x in inventory["never_author"]}
|
|
100
|
+
|
|
101
|
+
def marked(c):
|
|
102
|
+
for k, v in c["trailers"]:
|
|
103
|
+
if k.lower() == "assisted-by":
|
|
104
|
+
return True
|
|
105
|
+
ident = _ident(v)
|
|
106
|
+
if k.lower() == "co-authored-by" and ident and ident[1] in marked_emails:
|
|
107
|
+
return True
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
reverted = {c["subject"][len('Revert "'):-1] for c in commits if c["subject"].startswith('Revert "') and c["subject"].endswith('"')}
|
|
111
|
+
by_file = {}
|
|
112
|
+
for i, c in enumerate(commits):
|
|
113
|
+
for f in c["files"]:
|
|
114
|
+
by_file.setdefault(f, []).append(i)
|
|
115
|
+
stats = {True: Counter(), False: Counter()}
|
|
116
|
+
for i, c in enumerate(commits):
|
|
117
|
+
s = stats[marked(c)]
|
|
118
|
+
s["commits"] += 1
|
|
119
|
+
s["reverted"] += c["subject"] in reverted
|
|
120
|
+
s["fixes"] += bool(re.match(r"^(fix|hotfix|bugfix)(\([^)]*\))?!?:", c["subject"], re.I) or re.search(r"\b(fix|fixes|fixed|bug)\b", c["subject"], re.I))
|
|
121
|
+
again = False
|
|
122
|
+
for f in c["files"]: # the next commit to touch each file, by position: the list is in commit order
|
|
123
|
+
k = bisect.bisect_right(by_file[f], i)
|
|
124
|
+
if k < len(by_file[f]) and commits[by_file[f][k]]["time"] - c["time"] <= RETOUCH_DAYS * 86400:
|
|
125
|
+
again = True
|
|
126
|
+
break
|
|
127
|
+
s["retouched"] += again
|
|
128
|
+
total = len(commits)
|
|
129
|
+
return {"definition": "an Assisted-by trailer, or a co-author who never authors a commit here",
|
|
130
|
+
"share": round(stats[True]["commits"] / total, 3) if total else 0.0,
|
|
131
|
+
"cohort": dict(stats[True]) or {"commits": 0}, "rest": dict(stats[False]) or {"commits": 0}}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def shape(commits: list) -> dict:
|
|
135
|
+
"""How commits arrive, as neutral numbers: the share landing in runs of five or more by one author
|
|
136
|
+
within ten minutes of each other, the share with conventional-commit subjects, and how many hours
|
|
137
|
+
of the day (in UTC) see commits."""
|
|
138
|
+
burst = set()
|
|
139
|
+
by_author = {}
|
|
140
|
+
for i, c in enumerate(commits):
|
|
141
|
+
by_author.setdefault(c["email"], []).append(i)
|
|
142
|
+
for idx in by_author.values():
|
|
143
|
+
run = [idx[0]]
|
|
144
|
+
for a, b in zip(idx, idx[1:]):
|
|
145
|
+
if commits[b]["time"] - commits[a]["time"] <= BURST_SECONDS:
|
|
146
|
+
run.append(b)
|
|
147
|
+
else:
|
|
148
|
+
if len(run) >= BURST_SIZE:
|
|
149
|
+
burst.update(run)
|
|
150
|
+
run = [b]
|
|
151
|
+
if len(run) >= BURST_SIZE:
|
|
152
|
+
burst.update(run)
|
|
153
|
+
n = len(commits)
|
|
154
|
+
hours = {dt.datetime.fromtimestamp(c["time"], dt.timezone.utc).hour for c in commits}
|
|
155
|
+
return {"burst_share": round(len(burst) / n, 3) if n else 0.0,
|
|
156
|
+
"conventional_share": round(sum(bool(_CONVENTIONAL.match(c["subject"])) for c in commits) / n, 3) if n else 0.0,
|
|
157
|
+
"hours_used": len(hours)}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
INSTRUCTIONS = ("AGENTS.md", "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md")
|
|
161
|
+
GUARDRAILS = (".claude/settings.json", ".cursor/hooks.json")
|
|
162
|
+
MCP = (".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json")
|
|
163
|
+
LOCAL = (".claude/settings.local.json",)
|
|
164
|
+
_REFERENCE = re.compile(r"^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$|^\$\{[^}]+\}$|^\{\{.*\}\}$")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _json(repo: str, path: str):
|
|
168
|
+
try:
|
|
169
|
+
with open(os.path.join(repo, path), encoding="utf-8", errors="replace") as fh:
|
|
170
|
+
return json.load(fh)
|
|
171
|
+
except (OSError, ValueError):
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _literal_secret(value) -> bool:
|
|
176
|
+
"""A literal environment value that could be a credential: a string, not a ${VAR} reference, not a
|
|
177
|
+
placeholder shape, and long enough to be a key (a short word is configuration)."""
|
|
178
|
+
if not isinstance(value, str):
|
|
179
|
+
return False
|
|
180
|
+
v = value.strip()
|
|
181
|
+
return len(v) >= 16 and not _REFERENCE.match(v) and not leaks.is_placeholder(v)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def agents(repo: str) -> dict:
|
|
185
|
+
tracked = set(filetypes.git_paths(repo, "ls-files"))
|
|
186
|
+
head_count = int(subprocess.run(["git", "rev-list", "--count", "HEAD"], cwd=repo, capture_output=True, text=True).stdout.strip() or 0)
|
|
187
|
+
instructions = []
|
|
188
|
+
for path in sorted(p for p in tracked if p.rsplit("/", 1)[-1] in ("AGENTS.md", "CLAUDE.md", "GEMINI.md") or p in INSTRUCTIONS):
|
|
189
|
+
last = subprocess.run(["git", "log", "-1", "--format=%H%x1f%cs", "--", path], cwd=repo, capture_output=True, text=True).stdout.strip()
|
|
190
|
+
if not last:
|
|
191
|
+
continue
|
|
192
|
+
sha, day = last.split("\x1f")
|
|
193
|
+
behind = int(subprocess.run(["git", "rev-list", "--count", f"{sha}..HEAD"], cwd=repo, capture_output=True, text=True).stdout.strip() or 0)
|
|
194
|
+
instructions.append({"file": path, "last": day, "commits_behind": behind})
|
|
195
|
+
guard, disabled = [], []
|
|
196
|
+
for path in GUARDRAILS:
|
|
197
|
+
data = _json(repo, path) if path in tracked else None
|
|
198
|
+
if not isinstance(data, dict):
|
|
199
|
+
continue
|
|
200
|
+
if data.get("hooks"):
|
|
201
|
+
guard.append(path)
|
|
202
|
+
perms = data.get("permissions") if isinstance(data.get("permissions"), dict) else {}
|
|
203
|
+
if perms.get("defaultMode") == "bypassPermissions":
|
|
204
|
+
disabled.append({"file": path, "setting": "permissions.defaultMode=bypassPermissions"})
|
|
205
|
+
if data.get("skipDangerousModePermissionPrompt") is True:
|
|
206
|
+
disabled.append({"file": path, "setting": "skipDangerousModePermissionPrompt=true"})
|
|
207
|
+
mcp = []
|
|
208
|
+
for path in MCP:
|
|
209
|
+
data = _json(repo, path) if path in tracked else None
|
|
210
|
+
if not isinstance(data, dict):
|
|
211
|
+
continue
|
|
212
|
+
servers = data.get("mcpServers") or data.get("servers") or {}
|
|
213
|
+
if not isinstance(servers, dict):
|
|
214
|
+
continue
|
|
215
|
+
literal = [{"server": name, "key": key} for name, spec in sorted(servers.items()) if isinstance(spec, dict)
|
|
216
|
+
for key, value in sorted((spec.get("env") or {}).items()) if _literal_secret(value)]
|
|
217
|
+
mcp.append({"file": path, "servers": len(servers), "literal_env": literal})
|
|
218
|
+
return {"instructions": instructions, "head_commits": head_count, "guardrails": guard, "approval_disabled": disabled,
|
|
219
|
+
"local_settings": sorted(p for p in tracked if p in LOCAL or p.endswith("/.claude/settings.local.json")), "mcp": mcp}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main(argv=None) -> int:
|
|
223
|
+
args = sys.argv[1:] if argv is None else argv
|
|
224
|
+
if len(args) != 1:
|
|
225
|
+
print("usage: provenance.py OUT_DIR", file=sys.stderr)
|
|
226
|
+
return 2
|
|
227
|
+
repo = os.getcwd()
|
|
228
|
+
try:
|
|
229
|
+
commits = read_commits(repo)
|
|
230
|
+
except subprocess.CalledProcessError as e:
|
|
231
|
+
print(f"provenance.py: {(e.stderr or b'').decode('utf-8', 'replace').strip() or e}", file=sys.stderr)
|
|
232
|
+
return 1
|
|
233
|
+
inventory = trailers(commits)
|
|
234
|
+
result = {"trailers": inventory, "cohort": cohort(commits, inventory), "shape": shape(commits), "agents": agents(repo)}
|
|
235
|
+
with open(os.path.join(args[0], "provenance.json"), "w", encoding="utf-8") as fh:
|
|
236
|
+
json.dump(result, fh)
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
sys.exit(main())
|
|
@@ -289,6 +289,12 @@ def pulse(report: dict) -> list:
|
|
|
289
289
|
signed = signing_phrase(report)
|
|
290
290
|
if signed:
|
|
291
291
|
out.append(signed)
|
|
292
|
+
dup = report.get("duplicates") or {}
|
|
293
|
+
then = dup.get("then") or {}
|
|
294
|
+
if dup.get("rate") is not None and then.get("rate") is not None and max(dup["rate"], then["rate"]) >= 0.5:
|
|
295
|
+
now, before = dup["rate"], then["rate"]
|
|
296
|
+
way = "as a year before" if round(now, 1) == round(before, 1) else f"{'up' if now > before else 'down'} from {before:.1f}% a year before"
|
|
297
|
+
out.append(f"{now:.1f}% of lines duplicated, {way}")
|
|
292
298
|
return out
|
|
293
299
|
|
|
294
300
|
|
|
@@ -517,6 +523,27 @@ def signing_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
517
523
|
return _section("Signing by year", columns, rows, caption="; ".join(parts))
|
|
518
524
|
|
|
519
525
|
|
|
526
|
+
def trailers_section(report: dict, full: bool = True, width=None) -> dict:
|
|
527
|
+
"""The trailer keys the history carries, with the cohort comparison and the neutral commit-shape
|
|
528
|
+
descriptors below: --full and Markdown only. Read, never inferred; nothing is labelled."""
|
|
529
|
+
prov = report.get("provenance") or {}
|
|
530
|
+
tr, co, sh = prov.get("trailers") or {}, prov.get("cohort") or {}, prov.get("shape") or {}
|
|
531
|
+
columns = [("trailer", {}), ("commits", RIGHT), ("share", RIGHT)]
|
|
532
|
+
total = tr.get("commits") or 0
|
|
533
|
+
rows = [(k, n, _pct(n, total)) for k, n in (tr.get("keys") or {}).items()]
|
|
534
|
+
notes = []
|
|
535
|
+
marked, rest = co.get("cohort") or {}, co.get("rest") or {}
|
|
536
|
+
if marked.get("commits"):
|
|
537
|
+
def pair(key):
|
|
538
|
+
return f"{_pct(marked.get(key, 0), marked['commits'])} against {_pct(rest.get(key, 0), rest.get('commits') or 0)}"
|
|
539
|
+
notes.append(f"marked commits ({co.get('definition')}): {marked['commits']:,}, {round(100 * co.get('share', 0))}% of the history; "
|
|
540
|
+
f"reverted {pair('reverted')} for the rest, fixes {pair('fixes')}, a file changed again within two weeks {pair('retouched')}")
|
|
541
|
+
if sh:
|
|
542
|
+
notes.append(f"{round(100 * sh.get('burst_share', 0))}% of commits land in bursts of five or more within ten minutes; "
|
|
543
|
+
f"{round(100 * sh.get('conventional_share', 0))}% have conventional-commit subjects; commits come in {sh.get('hours_used', 0)} hours of the day")
|
|
544
|
+
return _section("Trailers", columns, rows, note=None if rows else "no trailers", caption="\n".join(notes) or None)
|
|
545
|
+
|
|
546
|
+
|
|
520
547
|
def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
|
|
521
548
|
"""Change frequency times size, Tornhill-style. Files no longer in the tree sort last. Drawn
|
|
522
549
|
under `--full` and in the Markdown export only; the default terminal report leaves it to the
|
|
@@ -740,10 +767,10 @@ def compare_section(result: dict) -> dict:
|
|
|
740
767
|
|
|
741
768
|
|
|
742
769
|
BUILDERS = [watch_section, size_section, people_section, knowledge_section, activity_section, timeline_section,
|
|
743
|
-
hotspots_section, coupling_section, signing_section, age_section, functions_section, health_section]
|
|
770
|
+
hotspots_section, coupling_section, signing_section, trailers_section, age_section, functions_section, health_section]
|
|
744
771
|
# `--full` and Markdown only: Size, Activity and Code age are interesting once and rarely change what you
|
|
745
772
|
# do next; Hotspots ranks the files the watch list already leads with, by the same product.
|
|
746
|
-
FULL_ONLY = {"size", "activity", "age", "hotspots", "signing"}
|
|
773
|
+
FULL_ONLY = {"size", "activity", "age", "hotspots", "signing", "trailers"}
|
|
747
774
|
|
|
748
775
|
|
|
749
776
|
def sections(report: dict, full: bool = True, width=None) -> list:
|
|
@@ -195,7 +195,7 @@ def manifest(repo_dir: str, args, version_of=tool_version) -> dict:
|
|
|
195
195
|
# Everything a run writes besides meta.json and run.log. Removed before each run so a reused
|
|
196
196
|
# --out directory never shows a previous run's data as this run's (a step skipped or killed
|
|
197
197
|
# this time would otherwise leave last time's file in place).
|
|
198
|
-
OUTPUTS = ["size.json", "repo-health.txt", "secrets.json", "dependencies.json", "log.txt", "activity.json", "functions.csv", "signing.json", "hygiene.json", "unreachable.json", "structure.json",
|
|
198
|
+
OUTPUTS = ["size.json", "repo-health.txt", "secrets.json", "dependencies.json", "log.txt", "activity.json", "functions.csv", "signing.json", "hygiene.json", "unreachable.json", "structure.json", "provenance.json",
|
|
199
199
|
"duplicates.json", "duplicates.txt", # duplicates.txt: what lizard's finder wrote before jscpd
|
|
200
200
|
"theseus/cohorts.json", "theseus/authors.json", "theseus/survival.json", "code-age.png", "survival.png", "trend.json"]
|
|
201
201
|
OUTPUT_GLOBS = ["maat-*.csv"]
|
|
@@ -242,7 +242,7 @@ def ignore_revs_files(repo_dir: str) -> list:
|
|
|
242
242
|
def plan(repo_dir: str, out_dir: str, branch: str = "HEAD", age: bool = True, plots: bool = False,
|
|
243
243
|
procs: int = None, interval: int = MONTH, ignore=(), types: str = None, now: str = None, since: str = None,
|
|
244
244
|
lizard: bool = False, duplicates: bool = True, trend: bool = True, samples: int = 12, backtest: str = None,
|
|
245
|
-
ignore_revs=(), structure: bool = False) -> list:
|
|
245
|
+
ignore_revs=(), structure: bool = False, duplicates_then: str = None) -> list:
|
|
246
246
|
o = lambda name: os.path.join(out_dir, name) # noqa: E731
|
|
247
247
|
log = o("log.txt")
|
|
248
248
|
ignores = [x for pattern in ignore for x in ("--ignore", pattern)]
|
|
@@ -263,6 +263,7 @@ def plan(repo_dir: str, out_dir: str, branch: str = "HEAD", age: bool = True, pl
|
|
|
263
263
|
{"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"), *revs_args], "stdout": None, "deps": ["git-log"]},
|
|
264
264
|
{"name": "signing", "argv": [sys.executable, "-m", "gitmole.signing", out_dir], "stdout": None, "deps": []}, # the gpgsig headers, no keyring
|
|
265
265
|
{"name": "hygiene", "argv": [sys.executable, "-m", "gitmole.hygiene", out_dir], "stdout": None, "deps": []}, # the Scorecard checks, from the clone
|
|
266
|
+
{"name": "provenance", "argv": [sys.executable, "-m", "gitmole.provenance", out_dir], "stdout": None, "deps": []}, # trailers, cohorts, agent files
|
|
266
267
|
]
|
|
267
268
|
workers = procs or blame.default_procs()
|
|
268
269
|
if lizard:
|
|
@@ -271,7 +272,8 @@ def plan(repo_dir: str, out_dir: str, branch: str = "HEAD", age: bool = True, pl
|
|
|
271
272
|
if structure: # tree-sitter: nesting, cognitive complexity, debt markers, the import graph; gitmole[structure] only
|
|
272
273
|
steps.append({"name": "structure", "argv": [sys.executable, "-m", "gitmole.structure", out_dir, "--procs", str(workers)], "stdout": None, "deps": []})
|
|
273
274
|
if duplicates:
|
|
274
|
-
steps.append({"name": "duplicates", "argv": [sys.executable, DUPLICATES_SCRIPT, repo_dir, out_dir, "--procs", str(workers), *ignores, *type_args
|
|
275
|
+
steps.append({"name": "duplicates", "argv": [sys.executable, DUPLICATES_SCRIPT, repo_dir, out_dir, "--procs", str(workers), *ignores, *type_args,
|
|
276
|
+
*(["--then", duplicates_then] if duplicates_then else [])], # the rate a year back: a direction
|
|
275
277
|
"stdout": None, "deps": []})
|
|
276
278
|
if trend:
|
|
277
279
|
steps.append({"name": "trend", "argv": [sys.executable, "-m", "gitmole.trend", out_dir, "--samples", str(samples)],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.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
|
|
@@ -26,6 +26,7 @@ gitmole/leaks.py
|
|
|
26
26
|
gitmole/load.py
|
|
27
27
|
gitmole/loss.py
|
|
28
28
|
gitmole/maat.py
|
|
29
|
+
gitmole/provenance.py
|
|
29
30
|
gitmole/render.py
|
|
30
31
|
gitmole/run.py
|
|
31
32
|
gitmole/sarif.py
|
|
@@ -66,6 +67,7 @@ tests/test_load.py
|
|
|
66
67
|
tests/test_loss.py
|
|
67
68
|
tests/test_maat.py
|
|
68
69
|
tests/test_packaging.py
|
|
70
|
+
tests/test_provenance.py
|
|
69
71
|
tests/test_render.py
|
|
70
72
|
tests/test_render_examples.py
|
|
71
73
|
tests/test_run.py
|
|
@@ -103,6 +103,19 @@ class Script(unittest.TestCase):
|
|
|
103
103
|
pair("src/a.py", "untracked.py", fragment="def f(x): secret sauce")],
|
|
104
104
|
"statistics": {"total": {"percentage": 50.0}}}
|
|
105
105
|
|
|
106
|
+
def test_then_measures_the_tree_as_it_stood_at_a_date_as_well(self):
|
|
107
|
+
p, argv, cwd, written, leftovers, repo = self._run(self.REPORT, extra=["--then", "2099-01-01"])
|
|
108
|
+
self.assertEqual(p.returncode, 0, p.stderr)
|
|
109
|
+
then = written["then"]
|
|
110
|
+
self.assertEqual(then["date"], "2099-01-01")
|
|
111
|
+
self.assertEqual(len(then["rev"]), 12)
|
|
112
|
+
self.assertEqual(then["files"], written["files"], "the code files tracked at that commit: here the same as HEAD's")
|
|
113
|
+
self.assertEqual(then["rate"], written["rate"], "the same tree, the same rate")
|
|
114
|
+
self.assertEqual(leftovers, ["duplicates.json"], "the exported tree is removed again")
|
|
115
|
+
p, _, _, written, _, _ = self._run(self.REPORT, extra=["--then", "1990-01-01"])
|
|
116
|
+
self.assertEqual(p.returncode, 0, p.stderr)
|
|
117
|
+
self.assertNotIn("then", written, "no commit before that date: nothing to compare")
|
|
118
|
+
|
|
106
119
|
def test_runs_jscpd_in_the_repo_and_writes_the_folded_blocks_without_the_fragments(self):
|
|
107
120
|
p, argv, cwd, written, leftovers, repo = self._run(self.REPORT, extra=["--ignore", "vendor/**"])
|
|
108
121
|
self.assertEqual(p.returncode, 0, p.stderr)
|
|
@@ -1207,3 +1207,43 @@ class Structure(unittest.TestCase):
|
|
|
1207
1207
|
r = self.base()
|
|
1208
1208
|
r["structure"] = {"status": "not-installed"}
|
|
1209
1209
|
self.assertFalse({"debt_in_hotspots", "deep_nesting", "hidden_coupling", "unreferenced_files"} & set(self.by_id(r)))
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
class AgentSurface(unittest.TestCase):
|
|
1213
|
+
def rep(self, agents=None, trailers=None):
|
|
1214
|
+
return report(provenance={"agents": agents or {}, "trailers": trailers or {"never_author": [], "signoff_by_co_author": []}},
|
|
1215
|
+
meta={"name": "r", "commits": 1000, "identities": [], "last_date": "2026-09-01"})
|
|
1216
|
+
|
|
1217
|
+
def by_id(self, r):
|
|
1218
|
+
return {f["rule"]["id"]: f for f in findings.evaluate(r)}
|
|
1219
|
+
|
|
1220
|
+
def test_approval_prompts_turned_off_and_personal_settings_tracked_are_warnings(self):
|
|
1221
|
+
found = self.by_id(self.rep(agents={"approval_disabled": [{"file": ".claude/settings.json", "setting": "permissions.defaultMode=bypassPermissions"}],
|
|
1222
|
+
"local_settings": [".claude/settings.local.json"]}))
|
|
1223
|
+
f = found["agent_approval_disabled"]
|
|
1224
|
+
self.assertEqual(f["severity"], "warning")
|
|
1225
|
+
self.assertIn(".claude/settings.json sets permissions.defaultMode=bypassPermissions", f["detail"])
|
|
1226
|
+
self.assertEqual(found["agent_local_settings"]["severity"], "warning")
|
|
1227
|
+
self.assertIn(".claude/settings.local.json is tracked", found["agent_local_settings"]["detail"])
|
|
1228
|
+
|
|
1229
|
+
def test_literal_values_in_an_mcp_declaration_name_the_key_never_the_value(self):
|
|
1230
|
+
f = self.by_id(self.rep(agents={"mcp": [{"file": ".mcp.json", "servers": 2, "literal_env": [{"server": "db", "key": "DB_URL"}]}]}))["mcp_literal_env"]
|
|
1231
|
+
self.assertEqual(f["severity"], "warning")
|
|
1232
|
+
self.assertIn("db sets DB_URL in .mcp.json to a literal value", f["detail"])
|
|
1233
|
+
self.assertIn("${DB_URL}", f["advice"])
|
|
1234
|
+
|
|
1235
|
+
def test_stale_instructions(self):
|
|
1236
|
+
found = self.by_id(self.rep(agents={"instructions": [{"file": "AGENTS.md", "last": "2025-01-01", "commits_behind": 640},
|
|
1237
|
+
{"file": "CLAUDE.md", "last": "2026-08-20", "commits_behind": 12}]}))
|
|
1238
|
+
f = found["agent_instructions_drift"]
|
|
1239
|
+
self.assertEqual(f["severity"], "info")
|
|
1240
|
+
self.assertIn("AGENTS.md last changed on 2025-01-01, 20 months and 640 commits before the last commit", f["detail"])
|
|
1241
|
+
self.assertNotIn("CLAUDE.md", f["detail"])
|
|
1242
|
+
|
|
1243
|
+
def test_a_sign_off_by_an_identity_that_only_co_authors(self):
|
|
1244
|
+
f = self.by_id(self.rep(trailers={"never_author": [], "signoff_by_co_author": [{"name": "Ghost", "email": "ghost@x.com", "commits": 3}]}))["signoff_by_co_author"]
|
|
1245
|
+
self.assertEqual(f["severity"], "info")
|
|
1246
|
+
self.assertIn("Ghost <ghost@x.com> signs off 3 commits but never authors one", f["detail"])
|
|
1247
|
+
self.assertEqual(self.by_id(self.rep()).get("signoff_by_co_author"), None)
|
|
1248
|
+
one = self.rep(trailers={"never_author": [], "signoff_by_co_author": [{"name": "Ghost", "email": "ghost@x.com", "commits": 1}]})
|
|
1249
|
+
self.assertIsNone(self.by_id(one).get("signoff_by_co_author"), "one commit is not a habit")
|
|
@@ -367,6 +367,11 @@ class LoadReport(unittest.TestCase):
|
|
|
367
367
|
r = load.load_report(out)
|
|
368
368
|
self.assertEqual(r["theseus_authors"], {"Bob": 90, "Ann": 10})
|
|
369
369
|
|
|
370
|
+
def test_the_duplication_rate_a_year_back_is_kept(self):
|
|
371
|
+
d = load.parse_duplicates_json({"rate": 6.1, "files": 10, "blocks": [], "then": {"date": "2025-09-17", "rev": "abc", "files": 9, "rate": 4.2}})
|
|
372
|
+
self.assertEqual(d["then"], {"date": "2025-09-17", "rev": "abc", "files": 9, "rate": 4.2})
|
|
373
|
+
self.assertNotIn("then", load.parse_duplicates_json({"rate": 6.1, "blocks": []}))
|
|
374
|
+
|
|
370
375
|
def test_no_signing_file_is_an_empty_record(self):
|
|
371
376
|
import os, tempfile
|
|
372
377
|
with tempfile.TemporaryDirectory() as out:
|
|
@@ -376,6 +381,7 @@ class LoadReport(unittest.TestCase):
|
|
|
376
381
|
self.assertEqual(load.load_report(out)["hygiene"], {})
|
|
377
382
|
self.assertEqual(load.load_report(out)["unreachable"], {})
|
|
378
383
|
self.assertEqual(load.load_report(out)["structure"], {})
|
|
384
|
+
self.assertEqual(load.load_report(out)["provenance"], {})
|
|
379
385
|
|
|
380
386
|
def test_missing_optional_file_gives_empty_value(self):
|
|
381
387
|
import tempfile
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from gitmole import provenance
|
|
9
|
+
|
|
10
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Repo:
|
|
14
|
+
def __init__(self, d):
|
|
15
|
+
self.d = d
|
|
16
|
+
self.git("init", "-q", "-b", "main")
|
|
17
|
+
|
|
18
|
+
def git(self, *args, name="Ann", email="ann@x.com", date="2026-01-05T10:00:00"):
|
|
19
|
+
date = date + "+00:00" # explicit: the hour count must not depend on the machine's time zone
|
|
20
|
+
env = dict(os.environ, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null", GIT_AUTHOR_NAME=name, GIT_AUTHOR_EMAIL=email,
|
|
21
|
+
GIT_COMMITTER_NAME=name, GIT_COMMITTER_EMAIL=email, GIT_AUTHOR_DATE=date, GIT_COMMITTER_DATE=date)
|
|
22
|
+
return subprocess.run(["git", *args], cwd=self.d, check=True, capture_output=True, env=env, text=True).stdout
|
|
23
|
+
|
|
24
|
+
def commit(self, path, text, message, **who):
|
|
25
|
+
full = os.path.join(self.d, path)
|
|
26
|
+
os.makedirs(os.path.dirname(full) or self.d, exist_ok=True)
|
|
27
|
+
with open(full, "a") as fh:
|
|
28
|
+
fh.write(text)
|
|
29
|
+
self.git("add", "-A", "-f", path, **who) # -f: a personal excludes file may ignore .claude/settings.local.json
|
|
30
|
+
self.git("commit", "-q", "-m", message, **who)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def history(d):
|
|
34
|
+
r = Repo(d)
|
|
35
|
+
r.commit("a.py", "1\n", "feat: start", date="2026-01-05T10:00:00")
|
|
36
|
+
r.commit("a.py", "2\n", "feat: helper\n\nCo-authored-by: Helper Agent <agent-bot@tools.example>", date="2026-01-05T10:02:00")
|
|
37
|
+
r.commit("b.py", "3\n", "feat: more\n\nAssisted-by: SomeModel v2\nSigned-off-by: Ann <ann@x.com>", date="2026-01-05T10:04:00")
|
|
38
|
+
r.commit("a.py", "4\n", "fix: a bug", date="2026-01-05T10:06:00")
|
|
39
|
+
r.commit("c.py", "5\n", "pair work\n\nCo-authored-by: Bob <bob@x.com>", date="2026-01-05T10:08:00")
|
|
40
|
+
r.commit("c.py", "6\n", "Bob's own", name="Bob", email="bob@x.com", date="2026-03-01T12:00:00")
|
|
41
|
+
r.commit("d.py", "7\n", "Revert \"feat: helper\"", date="2026-03-02T09:00:00")
|
|
42
|
+
r.commit("e.py", "8\n", "odd\n\nCo-authored-by: Ghost <ghost@x.com>\nSigned-off-by: Ghost <ghost@x.com>", date="2026-04-01T09:00:00")
|
|
43
|
+
r.commit("f.py", "9\n", "see the docs\n\nhttps://example.com/x\nNOTE: prose that ends a message", date="2026-04-02T09:00:00")
|
|
44
|
+
return r
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Trailers(unittest.TestCase):
|
|
48
|
+
def test_inventory_and_the_co_author_who_never_authors(self):
|
|
49
|
+
with tempfile.TemporaryDirectory() as d:
|
|
50
|
+
history(d)
|
|
51
|
+
commits = provenance.read_commits(d)
|
|
52
|
+
out = provenance.trailers(commits)
|
|
53
|
+
self.assertEqual(out["commits"], 9)
|
|
54
|
+
self.assertEqual(out["keys"], {"Co-authored-by": 3, "Signed-off-by": 2, "Assisted-by": 1}, "a URL and a line of prose are not trailers")
|
|
55
|
+
self.assertEqual(out["with_any"], 4)
|
|
56
|
+
self.assertEqual(out["never_author"], [{"name": "Ghost", "email": "ghost@x.com", "commits": 1},
|
|
57
|
+
{"name": "Helper Agent", "email": "agent-bot@tools.example", "commits": 1}],
|
|
58
|
+
"Bob co-authors and also authors, so he is not listed")
|
|
59
|
+
self.assertEqual(out["signoff_by_co_author"], [{"name": "Ghost", "email": "ghost@x.com", "commits": 1}],
|
|
60
|
+
"a sign-off by an identity that only ever co-authors: what the kernel's policy forbids agents")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Cohorts(unittest.TestCase):
|
|
64
|
+
def test_trailer_cohort_against_the_rest(self):
|
|
65
|
+
with tempfile.TemporaryDirectory() as d:
|
|
66
|
+
history(d)
|
|
67
|
+
commits = provenance.read_commits(d)
|
|
68
|
+
out = provenance.cohort(commits, provenance.trailers(commits))
|
|
69
|
+
self.assertEqual(out["definition"], "an Assisted-by trailer, or a co-author who never authors a commit here")
|
|
70
|
+
self.assertEqual((out["cohort"]["commits"], out["rest"]["commits"]), (3, 6))
|
|
71
|
+
self.assertEqual(out["cohort"]["reverted"], 1, "the helper commit was reverted by subject")
|
|
72
|
+
self.assertEqual(out["rest"]["reverted"], 0)
|
|
73
|
+
self.assertEqual(out["cohort"]["fixes"], 0)
|
|
74
|
+
self.assertEqual(out["rest"]["fixes"], 1)
|
|
75
|
+
self.assertEqual(out["cohort"]["retouched"], 1, "a.py changed again two minutes after the helper commit")
|
|
76
|
+
self.assertEqual(out["share"], 0.333)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Shape(unittest.TestCase):
|
|
80
|
+
def test_neutral_descriptors_of_how_commits_arrive(self):
|
|
81
|
+
with tempfile.TemporaryDirectory() as d:
|
|
82
|
+
history(d)
|
|
83
|
+
out = provenance.shape(provenance.read_commits(d))
|
|
84
|
+
self.assertEqual(out["burst_share"], 0.556, "five of nine commits land in a run of five or more within ten minutes of each other")
|
|
85
|
+
self.assertEqual(out["conventional_share"], 0.444, "feat: x3 and fix: x1")
|
|
86
|
+
self.assertEqual(out["hours_used"], 3)
|
|
87
|
+
self.assertNotIn("ai", json.dumps(out).lower().replace("assisted", ""), "a descriptor is never labelled")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Agents(unittest.TestCase):
|
|
91
|
+
def test_agent_configuration_as_a_declared_surface(self):
|
|
92
|
+
with tempfile.TemporaryDirectory() as d:
|
|
93
|
+
r = Repo(d)
|
|
94
|
+
r.commit("AGENTS.md", "rules\n", "agents", date="2025-01-01T10:00:00")
|
|
95
|
+
r.commit(".claude/settings.json", json.dumps({"hooks": {"PostToolUse": []}, "permissions": {"defaultMode": "bypassPermissions"}}), "settings",
|
|
96
|
+
date="2025-01-02T10:00:00")
|
|
97
|
+
r.commit(".claude/settings.local.json", "{}", "oops", date="2025-01-03T10:00:00")
|
|
98
|
+
r.commit(".mcp.json", json.dumps({"mcpServers": {"db": {"command": "x", "env": {"DB_URL": "postgres://u:" + "p4ss" * 4 + "@h/db",
|
|
99
|
+
"TOKEN": "${TOKEN}", "MODE": "readonly"}},
|
|
100
|
+
"web": {"command": "y"}}}), "mcp", date="2025-01-04T10:00:00")
|
|
101
|
+
for i in range(5):
|
|
102
|
+
r.commit("src.py", f"{i}\n", f"work {i}", date=f"2026-0{i + 1}-01T10:00:00")
|
|
103
|
+
out = provenance.agents(d)
|
|
104
|
+
self.assertEqual(out["instructions"], [{"file": "AGENTS.md", "last": "2025-01-01", "commits_behind": 8}])
|
|
105
|
+
self.assertEqual(out["guardrails"], [".claude/settings.json"])
|
|
106
|
+
self.assertEqual(out["approval_disabled"], [{"file": ".claude/settings.json", "setting": "permissions.defaultMode=bypassPermissions"}])
|
|
107
|
+
self.assertEqual(out["local_settings"], [".claude/settings.local.json"])
|
|
108
|
+
self.assertEqual(out["mcp"], [{"file": ".mcp.json", "servers": 2, "literal_env": [{"server": "db", "key": "DB_URL"}]}],
|
|
109
|
+
"a ${VAR} reference is where a secret is read from; a short word is configuration; the values themselves are never written")
|
|
110
|
+
self.assertNotIn("p4ss", json.dumps(out))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Step(unittest.TestCase):
|
|
114
|
+
def test_the_step_writes_provenance_json(self):
|
|
115
|
+
with tempfile.TemporaryDirectory() as d:
|
|
116
|
+
history(d)
|
|
117
|
+
out = os.path.join(d, "out")
|
|
118
|
+
os.makedirs(out)
|
|
119
|
+
p = subprocess.run([sys.executable, "-m", "gitmole.provenance", out], cwd=d, capture_output=True, text=True,
|
|
120
|
+
env=dict(os.environ, PYTHONPATH=ROOT, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null"))
|
|
121
|
+
self.assertEqual(p.returncode, 0, p.stderr)
|
|
122
|
+
with open(os.path.join(out, "provenance.json")) as fh:
|
|
123
|
+
data = json.load(fh)
|
|
124
|
+
self.assertEqual(set(data), {"trailers", "cohort", "shape", "agents"})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
unittest.main()
|
|
@@ -257,6 +257,32 @@ class Report(unittest.TestCase):
|
|
|
257
257
|
self.assertEqual(short, " · ".join(many[:6]) + " · 3 more")
|
|
258
258
|
self.assertEqual(full, " · ".join(many))
|
|
259
259
|
|
|
260
|
+
def test_the_duplication_direction_is_a_header_phrase(self):
|
|
261
|
+
r = sample_report()
|
|
262
|
+
r["duplicates"] = {"rate": 6.1, "blocks": [], "then": {"date": "2025-09-10", "rate": 4.2, "files": 30}}
|
|
263
|
+
self.assertIn("6.1% of lines duplicated, up from 4.2% a year before", render.pulse(r))
|
|
264
|
+
r["duplicates"]["then"]["rate"] = 6.1
|
|
265
|
+
self.assertIn("6.1% of lines duplicated, as a year before", render.pulse(r))
|
|
266
|
+
del r["duplicates"]["then"]
|
|
267
|
+
self.assertFalse([x for x in render.pulse(r) if "duplicated" in x])
|
|
268
|
+
|
|
269
|
+
def test_provenance_is_a_full_only_section_of_trailers_with_the_cohort_and_shape_below(self):
|
|
270
|
+
r = sample_report()
|
|
271
|
+
r["provenance"] = {"trailers": {"commits": 363, "keys": {"Co-authored-by": 40, "Signed-off-by": 12, "Assisted-by": 5}, "with_any": 50,
|
|
272
|
+
"never_author": [{"name": "Helper", "email": "h@x", "commits": 30}], "signoff_by_co_author": []},
|
|
273
|
+
"cohort": {"definition": "an Assisted-by trailer, or a co-author who never authors a commit here", "share": 0.096,
|
|
274
|
+
"cohort": {"commits": 35, "reverted": 2, "fixes": 4, "retouched": 20},
|
|
275
|
+
"rest": {"commits": 328, "reverted": 3, "fixes": 60, "retouched": 150}},
|
|
276
|
+
"shape": {"burst_share": 0.12, "conventional_share": 0.8, "hours_used": 20}, "agents": {}}
|
|
277
|
+
self.assertNotIn("Trailers", rendered(r, [], width=200))
|
|
278
|
+
block = _section_text(rendered(r, [], width=200, full=True), "Trailers")
|
|
279
|
+
self.assertRegex(block, r"Co-authored-by\s+40\s+11%")
|
|
280
|
+
caption = render.trailers_section(r)["caption"]
|
|
281
|
+
self.assertIn("marked commits (an Assisted-by trailer, or a co-author who never authors a commit here): 35, 10% of the history; "
|
|
282
|
+
"reverted 6% against 1% for the rest, fixes 11% against 18%, a file changed again within two weeks 57% against 46%", caption)
|
|
283
|
+
self.assertIn("12% of commits land in bursts of five or more within ten minutes; 80% have conventional-commit subjects; commits come in 20 hours of the day", caption)
|
|
284
|
+
self.assertIn("## Trailers", render.markdown(r, []))
|
|
285
|
+
|
|
260
286
|
def test_the_secrets_line_says_what_lay_outside_reachable_history(self):
|
|
261
287
|
r = sample_report()
|
|
262
288
|
r["unreachable"] = {"objects": 0, "blobs": 0, "scanned": 0, "findings": 0}
|
|
@@ -226,7 +226,7 @@ class Plan(unittest.TestCase):
|
|
|
226
226
|
def test_lists_every_tool_and_theseus_plots_depend_on_analyze(self):
|
|
227
227
|
steps = run.plan("/r", "/o")
|
|
228
228
|
names = [s["name"] for s in steps]
|
|
229
|
-
for expected in ["scc", "git-sizer", "betterleaks", "git-log", "change analysis", "code age", "signing", "hygiene"]:
|
|
229
|
+
for expected in ["scc", "git-sizer", "betterleaks", "git-log", "change analysis", "code age", "signing", "hygiene", "provenance"]:
|
|
230
230
|
self.assertIn(expected, names)
|
|
231
231
|
self.assertEqual(by_name(steps)["signing"]["argv"][1:], ["-m", "gitmole.signing", "/o"], "commit signing coverage, read from the objects, no keyring")
|
|
232
232
|
self.assertEqual(by_name(steps)["signing"]["deps"], [], "it reads meta.json for the bot names, written before the steps start")
|
|
@@ -250,6 +250,14 @@ class Plan(unittest.TestCase):
|
|
|
250
250
|
self.assertNotIn("--no-renames", by["git-log"]["argv"])
|
|
251
251
|
self.assertEqual(by["git-log"]["argv"][:4], ["git", "-c", "core.quotePath=false", "log"], "non-ASCII paths must not be octal-escaped and quoted")
|
|
252
252
|
|
|
253
|
+
def test_provenance_runs_as_a_module_and_duplicates_measure_a_year_back(self):
|
|
254
|
+
steps = by_name(run.plan("/r", "/o", duplicates_then="2025-09-17"))
|
|
255
|
+
self.assertEqual(steps["provenance"]["argv"][1:], ["-m", "gitmole.provenance", "/o"])
|
|
256
|
+
argv = steps["duplicates"]["argv"]
|
|
257
|
+
self.assertEqual(argv[argv.index("--then") + 1], "2025-09-17")
|
|
258
|
+
self.assertNotIn("--then", by_name(run.plan("/r", "/o"))["duplicates"]["argv"])
|
|
259
|
+
self.assertIn("provenance.json", run.OUTPUTS)
|
|
260
|
+
|
|
253
261
|
def test_the_structure_step_is_optional_and_runs_the_module(self):
|
|
254
262
|
self.assertNotIn("structure", by_name(run.plan("/r", "/o")))
|
|
255
263
|
step = by_name(run.plan("/r", "/o", structure=True, procs=3))["structure"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|