gitpro 0.3.1__py3-none-any.whl

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.
gitpro/audit.py ADDED
@@ -0,0 +1,800 @@
1
+ """`gitpro audit`: commits made as the wrong one of your identities, commits made with an AI agent, and the
2
+ trailer policy of the repo's identity.
3
+
4
+ Reading only, except `--rewrite`: it replays the unpushed commits of the current branch with git plumbing
5
+ (`commit-tree`, then one `update-ref` guarded by the old tip), fixing only the offending commits and
6
+ keeping every date; it never runs `rebase`, never touches pushed commits, the index or the work tree.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import shlex
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Dict, List, Optional, Sequence, Set, Tuple
19
+
20
+ from . import config, jsonout, resolve, trailers, undo
21
+ from .args import capability
22
+ from .config import Config, ConfigError
23
+
24
+ PROG = "gitpro"
25
+ DEFAULT_RANGE = "HEAD --not --remotes"
26
+ GIT_TIMEOUT = 10 # README §4.4: local git (small calls)
27
+ LONG_TIMEOUT = 120 # git log / rev-list over a whole history (--all on a big repo)
28
+ PUSHED_LIMIT = 1000 # pushed commits the default run checks (newest first); --all checks every one
29
+ SHOW = 10 # rows per section without -v
30
+ FORMAT = "%H%x00%an%x00%ae%x00%cn%x00%ce%x00%cI%x00%P%x00%s%x00%(trailers:only,unfold)%x1e"
31
+ # Options --range may contain besides revisions; anything else (--output=..., say) is refused.
32
+ RANGE_OPTIONS = ("--not", "--all", "--remotes", "--branches", "--tags", "--first-parent", "--no-merges",
33
+ "--merges", "--since", "--until", "--after", "--before", "--max-count")
34
+ _CAUSES = ("url_form", "override", "pin_stale", "global_override", "not_applied", "env_override")
35
+
36
+
37
+ @dataclass
38
+ class Commit:
39
+ sha: str
40
+ author_name: str
41
+ author_email: str
42
+ committer_name: str
43
+ committer_email: str
44
+ date: str # %cI
45
+ parents: Tuple[str, ...]
46
+ subject: str
47
+ trailers: List[Tuple[str, str]]
48
+ pushed: bool = False
49
+
50
+
51
+ # ---------------------------------------------------------------- git
52
+
53
+ def _git(repo: Path, *argv: str, stdin: Optional[str] = None, ok: Sequence[int] = (0,),
54
+ env: Optional[Dict[str, str]] = None, what: Optional[str] = None,
55
+ timeout: int = GIT_TIMEOUT) -> subprocess.CompletedProcess:
56
+ """git -C repo argv (never GIT_DIR & co. from the caller); ConfigError on a missing git, a timeout or
57
+ an exit code not in ok ("<what> failed: <stderr>")."""
58
+ try:
59
+ r = subprocess.run(["git", "-C", str(repo), *argv], input=stdin, capture_output=True, text=True,
60
+ encoding="utf-8", errors="replace", env=env or resolve._child_env(),
61
+ timeout=timeout, **({} if stdin is not None else {"stdin": subprocess.DEVNULL}))
62
+ except FileNotFoundError:
63
+ raise ConfigError("git not found") from None
64
+ except subprocess.TimeoutExpired:
65
+ raise ConfigError(f"git {argv[0]} timed out after {timeout}s") from None
66
+ if r.returncode not in ok:
67
+ raise ConfigError(f"{what or 'git ' + argv[0]} failed: {r.stderr.strip() or r.returncode}")
68
+ return r
69
+
70
+
71
+ def _lines(r: subprocess.CompletedProcess) -> List[str]:
72
+ return [ln for ln in r.stdout.splitlines() if ln.strip()]
73
+
74
+
75
+ def _parse_log(out: str) -> List[Commit]:
76
+ commits = []
77
+ for rec in out.split("\x1e"):
78
+ rec = rec.lstrip("\n")
79
+ if not rec:
80
+ continue
81
+ f = rec.split("\x00", 8)
82
+ if len(f) != 9:
83
+ continue
84
+ commits.append(Commit(f[0], f[1], f[2], f[3], f[4], f[5], tuple(f[6].split()), f[7],
85
+ trailers.split_lines(f[8])))
86
+ return commits
87
+
88
+
89
+ def log(repo: Path, revs: Sequence[str], *, shas: Optional[Sequence[str]] = None) -> List[Commit]:
90
+ """The commits of a rev-list range (revs), newest first, or exactly `shas` in that order: one git log."""
91
+ argv = ["log", "--no-color", "--no-show-signature", f"--format={FORMAT}"]
92
+ if shas is not None:
93
+ if not shas:
94
+ return []
95
+ return _parse_log(_git(repo, *argv, "--no-walk=unsorted", "--stdin", stdin="".join(s + "\n" for s in shas),
96
+ what="git log", timeout=LONG_TIMEOUT).stdout)
97
+ return _parse_log(_git(repo, *argv, *revs, "--", what="git rev-list", timeout=LONG_TIMEOUT).stdout)
98
+
99
+
100
+ def range_tokens(text: str) -> List[str]:
101
+ """--range split like a shell would; an option other than RANGE_OPTIONS is refused."""
102
+ try:
103
+ tokens = shlex.split(text)
104
+ except ValueError as e:
105
+ raise ConfigError(f"--range: {e}") from None
106
+ if not tokens:
107
+ raise ConfigError("--range is empty")
108
+ for t in tokens:
109
+ if t.startswith("-") and t.split("=", 1)[0] not in RANGE_OPTIONS:
110
+ raise ConfigError(f"--range: {t} is not a revision")
111
+ return tokens
112
+
113
+
114
+ def _branch(top: Path) -> Optional[str]:
115
+ r = _git(top, "symbolic-ref", "-q", "--short", "HEAD", ok=(0, 1, 128))
116
+ return r.stdout.strip() or None if r.returncode == 0 else None
117
+
118
+
119
+ def _has_head(top: Path) -> bool:
120
+ return _git(top, "rev-parse", "-q", "--verify", "HEAD^{commit}", ok=range(256)).returncode == 0
121
+
122
+
123
+ # ---------------------------------------------------------------- classification
124
+
125
+ def classify(commit: Commit, cfg: Config) -> List[dict]:
126
+ """Agent hits of one commit: agent_trailer (a Co-Authored-By whose email is in [agents].bot_emails),
127
+ agent_author (author or committer email in bot_emails), co_authored (any other Co-Authored-By)."""
128
+ bots = cfg.agents.bot_emails
129
+ out: List[dict] = []
130
+ for k, v in commit.trailers:
131
+ if trailers.is_co_author(k):
132
+ bot = any(trailers.glob_match(g, trailers.email_of(v)) for g in bots)
133
+ out.append({"kind": "agent_trailer" if bot else "co_authored", "who": v})
134
+ seen: Set[str] = set()
135
+ for name, email in ((commit.author_name, commit.author_email), (commit.committer_name, commit.committer_email)):
136
+ if email.casefold() not in seen and any(trailers.glob_match(g, email) for g in bots):
137
+ seen.add(email.casefold())
138
+ out.append({"kind": "agent_author", "who": f"{name} <{email}>".strip()})
139
+ return out
140
+
141
+
142
+ def _violations(c: Commit, policy: trailers.Policy, own_cf: Set[str]) -> List[str]:
143
+ """Trailer policy breaches of one of the user's own commits (a colleague's commits are never judged;
144
+ merge commits need no Co-Authored-By under require)."""
145
+ if c.author_email.casefold() not in own_cf and c.committer_email.casefold() not in own_cf:
146
+ return []
147
+ p = policy
148
+ if len(c.parents) > 1 and p.co_authored_by == "require":
149
+ p = trailers.Policy("allow", p.forbid_emails, p.who, p.table, p.emails_table)
150
+ return trailers.check(c.trailers, p)
151
+
152
+
153
+ # ---------------------------------------------------------------- the audit
154
+
155
+ def _row(c: Commit) -> dict:
156
+ return {"sha": c.sha, "date": c.date, "subject": c.subject, "pushed": c.pushed}
157
+
158
+
159
+ def collect(repo: Path, cfg: Config, range: Optional[str] = None, all: bool = False, *,
160
+ home: Optional[Path] = None, authors: bool = True, agents: bool = True) -> dict:
161
+ """The audit of repo as the `--json` object of spec 08 §3 (without "command"; "ok" included), plain
162
+ JSON values only. Reads only. range: a git rev-list range (shell-split); all: every commit of HEAD;
163
+ authors / agents: run that audit (the other one's keys are null)."""
164
+ return _public(_collect(repo, cfg, range, all, home=home, authors=authors, agents=agents))
165
+
166
+
167
+ def _collect(repo: Path, cfg: Config, range: Optional[str] = None, all: bool = False, *,
168
+ home: Optional[Path] = None, authors: bool = True, agents: bool = True) -> dict:
169
+ """collect() plus keys starting with "_" that hold Python objects for render() and rewrite()."""
170
+ resolve.require_git()
171
+ top = resolve.is_repo(Path(repo))
172
+ if top is None:
173
+ raise ConfigError(f"{repo}: not a git repository")
174
+ report = resolve.compare(top, cfg, home=home)
175
+ exp = report.expected
176
+ warnings: List[str] = []
177
+ branch = _branch(top)
178
+ pushed_set = set(_lines(_git(top, "rev-list", "--remotes", timeout=LONG_TIMEOUT)))
179
+ label, checked_all = DEFAULT_RANGE, True
180
+ if range:
181
+ tokens = range_tokens(range)
182
+ _git(top, "rev-list", "--count", *tokens, "--", what="git rev-list", timeout=LONG_TIMEOUT)
183
+ commits = log(top, tokens)
184
+ label, total = range, len(commits)
185
+ for c in commits:
186
+ c.pushed = c.sha in pushed_set
187
+ n_pushed = sum(c.pushed for c in commits)
188
+ elif not _has_head(top):
189
+ commits, total, n_pushed = [], 0, 0
190
+ label = "HEAD" if all else DEFAULT_RANGE
191
+ else:
192
+ shas = _lines(_git(top, "rev-list", "HEAD"))
193
+ total = len(shas)
194
+ unpushed = [s for s in shas if s not in pushed_set]
195
+ pushed = [s for s in shas if s in pushed_set]
196
+ n_pushed = len(pushed)
197
+ if all:
198
+ label = "HEAD"
199
+ commits = log(top, ["HEAD"])
200
+ else:
201
+ checked_all = len(pushed) <= PUSHED_LIMIT
202
+ commits = log(top, [], shas=unpushed + pushed[:PUSHED_LIMIT])
203
+ for c in commits:
204
+ c.pushed = c.sha in pushed_set
205
+
206
+ data: dict = {
207
+ "repo": str(top), "branch": branch,
208
+ "identity": {"id": exp.id, "email": exp.email},
209
+ "range": label, "commits": total, "pushed": n_pushed, "unpushed": total - n_pushed,
210
+ "checked": len(commits),
211
+ "wrong_author": None, "rewrite": None, "agents": None, "co_authored": None, "policy": None,
212
+ "rewritten": None,
213
+ }
214
+ own = resolve.own_emails(cfg)
215
+ ok = True
216
+ if authors and exp.email is None:
217
+ warnings.append(f"no identity for {_where(report)}; author check skipped")
218
+ elif authors:
219
+ offending = {e for e in own if e != exp.email}
220
+ wrong = []
221
+ for c in commits:
222
+ why = "committer" if c.committer_email in offending else "author" if c.author_email in offending else None
223
+ if why:
224
+ wrong.append(dict(_row(c), author_email=c.author_email, committer_email=c.committer_email, why=why))
225
+ data["wrong_author"] = wrong
226
+ fix = {w["sha"] for w in wrong if not w["pushed"]}
227
+ data["rewrite"] = plan_rewrite(top, branch, fix)
228
+ data["_offending"] = offending
229
+ ok = not wrong
230
+ if agents:
231
+ policy = trailers.policy_for(exp.match.identity, cfg.default)
232
+ own_cf = {e.casefold() for e in own}
233
+ hits, co_authored, bad = [], 0, []
234
+ for c in commits:
235
+ kinds = classify(c, cfg)
236
+ hits += [dict(sha=c.sha, kind=k["kind"], who=k["who"], pushed=c.pushed, date=c.date, subject=c.subject,
237
+ committer_email=c.committer_email) for k in kinds if k["kind"] != "co_authored"]
238
+ co_authored += any(k["kind"] == "co_authored" for k in kinds)
239
+ msgs = _violations(c, policy, own_cf) if policy.active() else []
240
+ if msgs:
241
+ bad.append(dict(_row(c), committer_email=c.committer_email, messages=msgs))
242
+ data["agents"] = hits
243
+ data["co_authored"] = co_authored
244
+ data["policy"] = {"identity": exp.id, "co_authored_by": policy.co_authored_by,
245
+ "forbid_emails": list(policy.forbid_emails), "violations": len(bad), "commits": bad}
246
+ data["_policy"] = policy
247
+ ok = ok and not bad
248
+ data["ok"] = ok
249
+ if not checked_all:
250
+ warnings.append(f"checked the newest {PUSHED_LIMIT} of {n_pushed} pushed commits; --all checks every one")
251
+ if warnings:
252
+ data["warnings"] = warnings
253
+ data["_report"] = report
254
+ return data
255
+
256
+
257
+ def _where(report) -> str:
258
+ """<host>/<owner> of the repo's first remote, else the remote url, else the repo."""
259
+ urls = list(report.effective.remotes.values())
260
+ if not urls:
261
+ return resolve._tilde(str(report.repo))
262
+ host, owner, _ = resolve.parse_url(urls[0])
263
+ redact = getattr(resolve, "redact_url", None) or (lambda u: u)
264
+ return f"{host}/{owner}" if host and owner else redact(urls[0])
265
+
266
+
267
+ # ---------------------------------------------------------------- rewrite
268
+
269
+ def plan_rewrite(top: Path, branch: Optional[str], fix: Set[str]) -> dict:
270
+ """Whether the unpushed wrong-author commits `fix` can be replayed: the base is the newest commit of the
271
+ branch's first-parent line that a remote-tracking ref contains (the merge-base with its upstream in the
272
+ usual case); every commit above it must have one parent and the offenders must all be among them."""
273
+ out = {"possible": False, "base": None, "commits": len(fix), "replayed": 0, "reason": None}
274
+ if not fix:
275
+ out["reason"] = "no unpushed wrong-author commits"
276
+ return out
277
+ rows = [ln.split() for ln in _lines(_git(top, "rev-list", "--first-parent", "--parents", "HEAD",
278
+ "--not", "--remotes"))]
279
+ chain = [(r[0], tuple(r[1:])) for r in reversed(rows)]
280
+ if chain:
281
+ out["base"] = chain[0][1][0] if chain[0][1] else None
282
+ if branch is None:
283
+ out["reason"] = "HEAD is detached"
284
+ elif any(len(p) > 1 for _, p in chain):
285
+ out["reason"] = "merge commits in range"
286
+ elif not fix <= {s for s, _ in chain}:
287
+ out["reason"] = "some of them are not on the current branch"
288
+ else:
289
+ first = next(n for n, (s, _) in enumerate(chain) if s in fix)
290
+ out.update(possible=True, replayed=len(chain) - first)
291
+ out["_chain"] = chain
292
+ return out
293
+
294
+
295
+ _SIG_HEADERS = (b"gpgsig", b"gpgsig-sha256")
296
+ _RAW_IDENT = re.compile(rb"^(.*) <([^<>]*)> (.*)$", re.S)
297
+
298
+
299
+ def _raw_commits(top: Path, shas: Sequence[str]) -> Dict[str, bytes]:
300
+ """The raw objects (`git cat-file commit`) of shas, byte for byte, in one `cat-file --batch`."""
301
+ if not shas:
302
+ return {}
303
+ try:
304
+ r = subprocess.run(["git", "-C", str(top), "cat-file", "--batch"], input="".join(s + "\n" for s in shas).encode(),
305
+ capture_output=True, env=resolve._child_env(), timeout=LONG_TIMEOUT)
306
+ except FileNotFoundError:
307
+ raise ConfigError("git not found") from None
308
+ except subprocess.TimeoutExpired:
309
+ raise ConfigError(f"git cat-file timed out after {LONG_TIMEOUT}s") from None
310
+ if r.returncode:
311
+ sys.stderr.write(r.stderr.decode("utf-8", "replace"))
312
+ raise ConfigError("rewrite failed; the branch was not changed")
313
+ out: Dict[str, bytes] = {}
314
+ buf, pos = r.stdout, 0
315
+ for sha in shas:
316
+ nl = buf.index(b"\n", pos)
317
+ head = buf[pos:nl].split()
318
+ if len(head) != 3 or head[1] != b"commit":
319
+ raise ConfigError(f"cannot read commit {sha[:12]}")
320
+ size = int(head[2])
321
+ out[sha] = buf[nl + 1:nl + 1 + size]
322
+ pos = nl + 1 + size + 1
323
+ return out
324
+
325
+
326
+ def _split(raw: bytes) -> Tuple[List[List[bytes]], bytes]:
327
+ """(headers, message): each header is its first line plus its continuation lines (" ..."); message is
328
+ everything after the first blank line, untouched."""
329
+ head, sep, msg = raw.partition(b"\n\n")
330
+ headers: List[List[bytes]] = []
331
+ for line in head.split(b"\n"):
332
+ if line.startswith(b" ") and headers:
333
+ headers[-1].append(line)
334
+ else:
335
+ headers.append([line])
336
+ return headers, msg if sep else b""
337
+
338
+
339
+ def _key(h: List[bytes]) -> bytes:
340
+ return h[0].split(b" ", 1)[0]
341
+
342
+
343
+ def _value(headers: List[List[bytes]], key: bytes) -> Optional[bytes]:
344
+ return next((h[0][len(key) + 1:] for h in headers if _key(h) == key), None)
345
+
346
+
347
+ def is_signed(raw: bytes) -> bool:
348
+ return any(_key(h) in _SIG_HEADERS for h in _split(raw)[0])
349
+
350
+
351
+ def gpgsign(top: Path) -> bool:
352
+ """commit.gpgsign as git would apply it in top (git commit-tree itself ignores it)."""
353
+ r = _git(top, "config", "--bool", "commit.gpgsign", ok=range(256))
354
+ return r.returncode == 0 and r.stdout.strip() == "true"
355
+
356
+
357
+ def _ident_of(headers: List[List[bytes]], key: bytes, sha: str) -> Tuple[bytes, bytes, bytes]:
358
+ m = _RAW_IDENT.match(_value(headers, key) or b"")
359
+ if not m:
360
+ raise ConfigError(f"cannot read the {key.decode()} of {sha[:12]}")
361
+ return m[1], m[2], m[3]
362
+
363
+
364
+ def _encoded(text: str, headers: List[List[bytes]]) -> bytes:
365
+ enc = (_value(headers, b"encoding") or b"utf-8").decode("ascii", "replace")
366
+ try:
367
+ return text.encode(enc)
368
+ except (LookupError, UnicodeEncodeError):
369
+ return text.encode("utf-8")
370
+
371
+
372
+ def _write_commit(top: Path, body: bytes) -> str:
373
+ r = subprocess.run(["git", "-C", str(top), "hash-object", "-t", "commit", "-w", "--stdin"], input=body,
374
+ capture_output=True, env=resolve._child_env(), timeout=GIT_TIMEOUT)
375
+ if r.returncode or not r.stdout.strip():
376
+ sys.stderr.write(r.stderr.decode("utf-8", "replace"))
377
+ raise ConfigError("rewrite failed; the branch was not changed")
378
+ return r.stdout.decode().strip()
379
+
380
+
381
+ def _commit_tree_signed(top: Path, sha: str, headers: List[List[bytes]], msg: bytes, parent: Optional[str],
382
+ author: Tuple[bytes, bytes, bytes], committer: Tuple[bytes, bytes, bytes], tmp: Path) -> str:
383
+ """`git commit-tree -S` (signs as the user's config says: gpg.format, user.signingkey, gpg.program).
384
+ Name, email and date go through git's ident parser, so unusual idents are normalised (`;` stripped,
385
+ -0000 written as +0000) and extra headers are not kept; the tree, message and dates are."""
386
+ msgfile = tmp / "msg"
387
+ msgfile.write_bytes(msg)
388
+ env: Dict = dict(resolve._child_env())
389
+ for prefix, (name, email, date) in (("AUTHOR", author), ("COMMITTER", committer)):
390
+ stamp = date.split()
391
+ env[f"GIT_{prefix}_NAME".encode()] = name
392
+ env[f"GIT_{prefix}_EMAIL".encode()] = email
393
+ env[f"GIT_{prefix}_DATE".encode()] = b"@" + b" ".join(stamp[:2]) if stamp else date
394
+ encoding = _value(headers, b"encoding")
395
+ enc = ["-c", f"i18n.commitEncoding={encoding.decode('ascii', 'replace')}"] if encoding else []
396
+ argv = ["git", "-C", str(top), *enc, "commit-tree", "-S", f"{sha}^{{tree}}",
397
+ *(["-p", parent] if parent else []), "-F", str(msgfile)]
398
+ r = subprocess.run(argv, capture_output=True, env=env, stdin=subprocess.DEVNULL, timeout=LONG_TIMEOUT)
399
+ if r.returncode or not r.stdout.strip():
400
+ sys.stderr.write(r.stderr.decode("utf-8", "replace"))
401
+ raise ConfigError("rewrite failed; the branch was not changed")
402
+ return r.stdout.decode().strip()
403
+
404
+
405
+ def replay(top: Path, chain: Sequence[Tuple[str, Tuple[str, ...]]], fix: Set[str], offending: Set[str],
406
+ name: str, email: str, *, sign: Optional[bool] = None) -> Tuple[str, int, Dict[str, str]]:
407
+ """Recreate every commit of chain (oldest first) on its new parent; the author/committer of commits in
408
+ `fix` whose email is in `offending` (exact match) become name <email>, dates always the originals.
409
+ Commits below the first change keep their sha. Without signing, each new object is the original's raw
410
+ bytes with only the parent line (and the fixed ident lines) replaced and the now invalid signature
411
+ dropped, written with `git hash-object`; with commit.gpgsign set (or sign=True) every new commit goes
412
+ through `git commit-tree -S` instead. Returns (new tip, number of new commits, {old sha: new sha})."""
413
+ sign = gpgsign(top) if sign is None else sign
414
+ raws = _raw_commits(top, [s for s, _ in chain])
415
+ parent = chain[0][1][0] if chain[0][1] else None
416
+ mapping: Dict[str, str] = {}
417
+ with tempfile.TemporaryDirectory(prefix="gitpro-audit-") as tmp:
418
+ for sha, parents in chain:
419
+ headers, msg = _split(raws[sha])
420
+ idents = {}
421
+ fixed = False
422
+ for key in (b"author", b"committer"):
423
+ n, e, d = _ident_of(headers, key, sha)
424
+ if sha in fix and e.decode("utf-8", "surrogateescape") in offending:
425
+ n, e, fixed = _encoded(name, headers), email.encode("utf-8"), True
426
+ idents[key] = (n, e, d)
427
+ if not fixed and parent == (parents[0] if parents else None):
428
+ parent = sha
429
+ continue
430
+ if sign:
431
+ new = _commit_tree_signed(top, sha, headers, msg, parent, idents[b"author"], idents[b"committer"],
432
+ Path(tmp))
433
+ else:
434
+ out: List[bytes] = []
435
+ parent_done = False
436
+ for h in headers:
437
+ key = _key(h)
438
+ if key in _SIG_HEADERS:
439
+ continue
440
+ if key == b"parent":
441
+ if not parent_done and parent:
442
+ out.append(b"parent " + parent.encode())
443
+ parent_done = True
444
+ continue
445
+ if key in idents:
446
+ n, e, d = idents[key]
447
+ out.append(key + b" " + n + b" <" + e + b"> " + d)
448
+ continue
449
+ out.extend(h)
450
+ new = _write_commit(top, b"\n".join(out) + b"\n\n" + msg)
451
+ mapping[sha] = new
452
+ parent = new
453
+ return parent, len(mapping), mapping
454
+
455
+
456
+ def notes_of(top: Path, shas: Sequence[str]) -> List[str]:
457
+ """The shas among `shas` that carry a note in the default notes ref."""
458
+ r = _git(top, "notes", "list", ok=range(256), timeout=LONG_TIMEOUT)
459
+ if r.returncode:
460
+ return []
461
+ noted = {ln.split()[1] for ln in _lines(r) if len(ln.split()) == 2}
462
+ return [s for s in shas if s in noted]
463
+
464
+
465
+ def _copy_notes(top: Path, mapping: Dict[str, str]) -> Tuple[int, Optional[str]]:
466
+ """`git notes copy --stdin` for the replayed commits that have notes: (copied, error or None)."""
467
+ pairs = [(o, mapping[o]) for o in notes_of(top, list(mapping))]
468
+ if not pairs:
469
+ return 0, None
470
+ r = _git(top, "notes", "copy", "--stdin", stdin="".join(f"{o} {n}\n" for o, n in pairs), ok=range(256))
471
+ if r.returncode:
472
+ return 0, r.stderr.strip() or f"git notes copy exited {r.returncode}"
473
+ return len(pairs), None
474
+
475
+
476
+ def _in_progress(top: Path) -> Optional[str]:
477
+ gd = Path(_git(top, "rev-parse", "--absolute-git-dir").stdout.strip())
478
+ for name, what in (("rebase-merge", "a rebase"), ("rebase-apply", "a rebase"), ("MERGE_HEAD", "a merge"),
479
+ ("CHERRY_PICK_HEAD", "a cherry-pick"), ("REVERT_HEAD", "a revert")):
480
+ if (gd / name).exists():
481
+ return f"{what} is in progress"
482
+ return None
483
+
484
+
485
+ def _still_wrong(report) -> Optional[str]:
486
+ """The refusal when git would not commit as the expected identity next time, else None."""
487
+ exp = report.expected
488
+ env_p = next((p for p in report.problems if p.code == "env_override"), None)
489
+ now = report.effective.email.value
490
+ if env_p is None and now == exp.email:
491
+ return None
492
+ if env_p is not None:
493
+ now = os.environ.get("GIT_COMMITTER_EMAIL") or os.environ.get("GIT_AUTHOR_EMAIL") or now
494
+ cause = next((p for p in report.problems if p.code in _CAUSES), None)
495
+ fix = resolve._shown_fix(cause) if cause is not None else ""
496
+ fix = fix or f"{PROG} why {shlex.quote(str(report.repo))}"
497
+ return f"git would still commit as {now or '(no email)'}; fix the repo first: {fix}"
498
+
499
+
500
+ def refuse_rewrite(data: dict) -> Optional[str]:
501
+ """Why --rewrite cannot run on this audit, or None. 'nothing' when there is nothing to do."""
502
+ top = Path(data["repo"])
503
+ if data["wrong_author"] is None:
504
+ return "no identity for this repo; nothing to rewrite to"
505
+ if not data["wrong_author"]:
506
+ return "nothing"
507
+ if data["branch"] is None:
508
+ return "HEAD is detached"
509
+ busy = _in_progress(top)
510
+ if busy:
511
+ return busy
512
+ if _lines(_git(top, "status", "--porcelain", "--untracked-files=no")):
513
+ return "working tree has changes; commit or stash them first"
514
+ wrong = _still_wrong(data["_report"])
515
+ if wrong:
516
+ return wrong
517
+ pushed = [w for w in data["wrong_author"] if w["pushed"]]
518
+ if pushed:
519
+ n = len(pushed)
520
+ return (f"{n} of the wrong-author commits {'is' if n == 1 else 'are'} pushed; rewrite only covers "
521
+ f"unpushed commits (use --range to pick them)")
522
+ rw = data["rewrite"]
523
+ if not rw["commits"]:
524
+ return "nothing"
525
+ if not rw["possible"]:
526
+ return f"cannot rewrite: {rw['reason']}; rebase by hand"
527
+ return None
528
+
529
+
530
+ def _undo_line(top: Path, branch: str, old: str, new: str) -> str:
531
+ return " ".join(shlex.quote(a) for a in ["git", "-C", str(top), "update-ref", "-m", "gitpro audit undo",
532
+ f"refs/heads/{branch}", old, new])
533
+
534
+
535
+ def rewrite(data: dict, exp_name: str, exp_email: str) -> dict:
536
+ """Replay data's rewrite plan (refuse_rewrite(data) must be None and the user must have agreed): undo
537
+ script first, then commit-tree, then one update-ref guarded by the old tip. {"count", "undo", ...}."""
538
+ from . import files
539
+ top, branch, rw = Path(data["repo"]), data["branch"], data["rewrite"]
540
+ chain = rw["_chain"]
541
+ old = _git(top, "rev-parse", "HEAD").stdout.strip()
542
+ if old != chain[-1][0]:
543
+ raise ConfigError(f"{branch} changed while {PROG} was running; nothing written")
544
+ fix = {w["sha"] for w in data["wrong_author"]}
545
+ script = undo.write("audit --rewrite", [_undo_line(top, branch, old, old)], what=f"branch {branch} of {top}")
546
+ try:
547
+ new, changed, mapping = replay(top, chain, fix, data["_offending"], exp_name, exp_email)
548
+ text = script.read_text(encoding="utf-8").replace(_undo_line(top, branch, old, old),
549
+ _undo_line(top, branch, old, new))
550
+ files.atomic_write(script, text, 0o700)
551
+ r = _git(top, "update-ref", "-m", f"{PROG} audit --rewrite", f"refs/heads/{branch}", new, old,
552
+ ok=range(256))
553
+ if r.returncode:
554
+ raise ConfigError(f"{branch} changed while {PROG} was running; nothing written")
555
+ except BaseException:
556
+ try:
557
+ script.unlink() # the branch was not changed: nothing to undo
558
+ except OSError:
559
+ pass
560
+ raise
561
+ notes, notes_error = _copy_notes(top, mapping)
562
+ return {"count": len(fix), "undo": str(script), "old_tip": old, "new_tip": new, "replayed": changed,
563
+ "notes": notes, "notes_error": notes_error}
564
+
565
+
566
+ # ---------------------------------------------------------------- text
567
+
568
+ def _short(text: str, n: int) -> str:
569
+ return text if len(text) <= n else text[:n - 1] + "…"
570
+
571
+
572
+ def _rows(items: List[dict], verbose: bool, fmt) -> List[str]:
573
+ shown = items if verbose else items[:SHOW]
574
+ out = [fmt(i) for i in shown]
575
+ if len(items) > len(shown):
576
+ out.append(f" … and {len(items) - len(shown)} more (-v lists all)")
577
+ return out
578
+
579
+
580
+ def _rewrite_cmd(data: dict, args) -> str:
581
+ argv = [PROG, "audit", "--rewrite"]
582
+ if getattr(args, "config", None):
583
+ argv += ["-c", str(args.config)]
584
+ rw = data["rewrite"]
585
+ if getattr(args, "range", None):
586
+ argv += ["--range", args.range]
587
+ elif any(w["pushed"] for w in data["wrong_author"]) and rw.get("base"):
588
+ argv += ["--range", f"{rw['base'][:12]}..HEAD"]
589
+ shown = " ".join(shlex.quote(a) for a in argv)
590
+ return shown + " " + _sh(resolve._tilde(data["repo"]))
591
+
592
+
593
+ def _sh(path: str) -> str:
594
+ return path if path == "~" else "~/" + shlex.quote(path[2:]) if path.startswith("~/") else shlex.quote(path)
595
+
596
+
597
+ def render(data: dict, args) -> List[str]:
598
+ """The text report, one line per item."""
599
+ verbose = bool(getattr(args, "verbose", False))
600
+ ident = data["identity"]
601
+ who = (f"identity {ident['id'] or '[default]'} <{ident['email']}>" if ident["email"] else "no identity")
602
+ where = f" in {data['range']}" if getattr(args, "range", None) else ""
603
+ lines = [f"{resolve._tilde(data['repo'])} {who} {data['commits']} commit{'s' * (data['commits'] != 1)}{where} "
604
+ f"({data['pushed']} pushed, {data['unpushed']} unpushed, as of last fetch)"]
605
+ wrong, agents, pol = data["wrong_author"], data["agents"], data["policy"]
606
+ nothing_else = not agents and not data["co_authored"] and not (pol and pol["violations"])
607
+ if wrong == []:
608
+ clean = f"✓ all {data['checked']} commits are committed as {ident['email']}"
609
+ lines += ["", clean + ("; no agent trailers" if agents is not None and nothing_else else "")]
610
+ elif wrong:
611
+ up = [w for w in wrong if not w["pushed"]]
612
+ lines += ["", f"wrong author ({len(up)} unpushed, {len(wrong) - len(up)} pushed)"]
613
+
614
+ def row(w: dict) -> str:
615
+ e = w["committer_email"] if w["why"] == "committer" else w["author_email"]
616
+ state = "pushed · report only" if w["pushed"] else "unpushed"
617
+ return f" {w['sha'][:7]} {w['date'][:10]} {e:<24} {_short(w['subject'], 32):<32} {state}"
618
+ lines += _rows(wrong, verbose, row)
619
+ rw = data["rewrite"]
620
+ if up and rw["possible"]:
621
+ n = len(up)
622
+ lines.append(f"→ rewrite the {n} unpushed: {_rewrite_cmd(data, args)}")
623
+ if rw["replayed"] > n:
624
+ lines.append(f" (fixes only {'that commit' if n == 1 else f'those {n} commits'} and keeps every date; "
625
+ f"the commits above them get new ids but are otherwise unchanged)")
626
+ elif up:
627
+ lines.append(f"→ {len(up)} unpushed commit{'s' * (len(up) != 1)} need{'s' * (len(up) == 1)} a manual "
628
+ f"rebase ({rw['reason']})")
629
+ if len(up) < len(wrong):
630
+ lines.append(" pushed commits are left alone; rewriting shared history needs a force push and "
631
+ "everyone's agreement")
632
+ if agents:
633
+ lines += ["", f"agent commits ({len({a['sha'] for a in agents})})"]
634
+ groups: Dict[Tuple[str, str], List[dict]] = {}
635
+ for a in agents:
636
+ groups.setdefault((a["kind"], a["who"]), []).append(a)
637
+ policy = data.get("_policy")
638
+ bad_shas = {b["sha"] for b in (pol["commits"] if pol else [])}
639
+ for (kind, who_), items in groups.items():
640
+ label = f"Co-Authored-By: {who_}" if kind == "agent_trailer" else f"author {who_}"
641
+ mark = ""
642
+ if kind == "agent_trailer" and policy is not None and policy.active():
643
+ bad = any(a["sha"] in bad_shas for a in items)
644
+ mark = f" policy {ident['id'] or '[default]'}: {policy.co_authored_by} {'✗' if bad else '✓'}"
645
+ lines.append(f" {label:<56} {len(items)} commit{'s' * (len(items) != 1)}{mark}")
646
+ lines += _rows(items, verbose, lambda a: f" {a['sha'][:7]} {a['date'][:10]} {a['committer_email']:<16} "
647
+ f"{_short(a['subject'], 28):<28} {label}")
648
+ if data["co_authored"]:
649
+ n = data["co_authored"]
650
+ lines += ["", f"co-authored by people: {n} commit{'s' * (n != 1)}"]
651
+ if pol and pol["violations"]:
652
+ n = pol["violations"]
653
+ policy = data["_policy"]
654
+ lines += ["", f"✗ trailer policy of {policy.who}: {n} commit{'s' * (n != 1)} violate{'s' * (n == 1)} it"]
655
+ lines += _rows(pol["commits"], verbose,
656
+ lambda b: f" {b['sha'][:7]} {b['date'][:10]} {_short(b['subject'], 28):<28} "
657
+ f"{'; '.join(b['messages'])}")
658
+ setting = (f"{policy.table} co_authored_by = \"{policy.co_authored_by}\"" if policy.table
659
+ else f"{policy.emails_table} forbid_emails")
660
+ lines.append(f"→ {setting} for {ident['id'] or '[default]'}; the commit-msg guard blocks new ones "
661
+ f"({PROG} guard status)")
662
+ return lines
663
+
664
+
665
+ # ---------------------------------------------------------------- command
666
+
667
+ def _public(data: dict) -> dict:
668
+ out = {k: v for k, v in data.items() if not k.startswith("_")}
669
+ if isinstance(out.get("rewrite"), dict):
670
+ out["rewrite"] = {k: v for k, v in out["rewrite"].items() if not k.startswith("_")}
671
+ return out
672
+
673
+
674
+ def _confirm(question: str, yes: bool) -> bool:
675
+ if yes:
676
+ return True
677
+ if not sys.stdin.isatty():
678
+ raise ConfigError("no terminal to confirm on; re-run with --yes")
679
+ from . import view
680
+ return view.confirm(question)
681
+
682
+
683
+ def _preview(data: dict, exp) -> List[str]:
684
+ rw = data["rewrite"]
685
+ branch = data["branch"]
686
+ base = rw["base"][:7] if rw["base"] else "the root"
687
+ lines = [f"→ rewrite {rw['commits']} unpushed commit{'s' * (rw['commits'] != 1)} on {branch} (base {base}):"]
688
+ off = data["_offending"]
689
+ for w in reversed([w for w in data["wrong_author"] if not w["pushed"]]):
690
+ what = [k for k, e in (("author", w["author_email"]), ("committer", w["committer_email"])) if e in off]
691
+ emails = sorted({w[f"{k}_email"] for k in what})
692
+ lines.append(f" {w['sha'][:7]} {_short(w['subject'], 32):<32} {', '.join(what)} {', '.join(emails)} "
693
+ f"→ {exp.email}")
694
+ rest = rw["replayed"] - rw["commits"]
695
+ if rest:
696
+ lines.append(f" {rest} descendant{'s' * (rest != 1)} get{'s' * (rest == 1)} new ids (same tree, message, "
697
+ f"author, committer and dates)")
698
+ top = Path(data["repo"])
699
+ replayed = [sha for sha, _ in rw["_chain"][len(rw["_chain"]) - rw["replayed"]:]]
700
+ signed = sum(is_signed(raw) for raw in _raw_commits(top, replayed).values())
701
+ r = rw["replayed"]
702
+ if gpgsign(top):
703
+ lines.append(f" the {r} new commit{'s are' if r != 1 else ' is'} signed (commit.gpgsign = true)"
704
+ + (f"; {signed} of them had a signature before" if signed else ""))
705
+ elif signed:
706
+ lines.append(f" {signed} signed commit{'s lose their' if signed != 1 else ' loses its'} signature "
707
+ f"(commit.gpgsign is off)")
708
+ noted = len(notes_of(top, replayed))
709
+ if noted:
710
+ lines.append(f" {noted} note{'s are' if noted != 1 else ' is'} copied to the new commit{'s' * (noted != 1)}")
711
+ lines.append(f" then: git -C {_sh(resolve._tilde(data['repo']))} update-ref -m 'gitpro audit --rewrite' "
712
+ f"refs/heads/{shlex.quote(branch)} <new tip> {rw['_chain'][-1][0][:12]}")
713
+ return lines
714
+
715
+
716
+ def cmd_audit(args) -> int:
717
+ as_json = jsonout.wants_json(args)
718
+ if args.rewrite and getattr(args, "agents", False):
719
+ raise ConfigError("--rewrite needs the author audit; drop --agents")
720
+ if args.rewrite and as_json and not args.yes:
721
+ raise ConfigError("--json needs -y with --rewrite (there is no terminal to confirm on)")
722
+ home = Path(args.home).expanduser().resolve()
723
+ path, how = config.find(args.config, home)
724
+ cfg = config.load_all(path, how)
725
+ data = _collect(Path(args.dir), cfg, args.range, args.all, home=home,
726
+ authors=not args.agents, agents=not args.authors)
727
+ if not as_json:
728
+ for w in data.get("warnings", []):
729
+ print(f"⚠ {w}", file=sys.stderr)
730
+ print("\n".join(render(data, args)))
731
+ if not args.rewrite:
732
+ if as_json:
733
+ jsonout.emit("audit", _public(data))
734
+ return 0 if data["ok"] else 1
735
+ why = refuse_rewrite(data)
736
+ if why == "nothing":
737
+ if as_json:
738
+ jsonout.emit("audit", _public(data))
739
+ else:
740
+ print("✓ nothing to rewrite: no unpushed wrong-author commits")
741
+ return 0 if data["ok"] else 1
742
+ if why:
743
+ raise ConfigError(why)
744
+ exp = data["_report"].expected
745
+ if not as_json:
746
+ print()
747
+ print("\n".join(_preview(data, exp)))
748
+ n = data["rewrite"]["commits"]
749
+ if not _confirm(f"rewrite {n} unpushed commit{'s' * (n != 1)}?", args.yes):
750
+ print("✗ aborted, nothing written")
751
+ return 1
752
+ done = rewrite(data, exp.name or "", exp.email)
753
+ rest_ok = not (data["policy"] and data["policy"]["violations"])
754
+ data["rewritten"] = {"count": done["count"], "undo": done["undo"], "old_tip": done["old_tip"],
755
+ "new_tip": done["new_tip"], "notes": done["notes"]}
756
+ if done["notes_error"]:
757
+ data.setdefault("warnings", []).append(f"notes not copied: {done['notes_error']}")
758
+ data["ok"] = rest_ok
759
+ if as_json:
760
+ jsonout.emit("audit", _public(data))
761
+ else:
762
+ c, r = done["count"], done["replayed"]
763
+ print(f"✓ rewrote {c} commit{'s' * (c != 1)} ({r} new sha{'s' * (r != 1)}: {c} fixed, "
764
+ f"{r - c} descendant{'s' * (r - c != 1)} re-parented)")
765
+ if done["notes"]:
766
+ print(f"✓ copied {done['notes']} note{'s' * (done['notes'] != 1)} to the new commits")
767
+ if done["notes_error"]:
768
+ print(f"⚠ notes not copied: {done['notes_error']}", file=sys.stderr)
769
+ print(f"↩ undo: sh {_sh(undo.tilde(Path(done['undo'])))}")
770
+ return 0 if rest_ok else 1
771
+
772
+
773
+ def register(sub, common, confirm) -> None:
774
+ sp = sub.add_parser(
775
+ "audit", parents=[common, confirm],
776
+ help="find commits made as the wrong identity or with an AI agent; fix unpushed ones with --rewrite",
777
+ description="Audit the history of a repo: commits whose committer (or your own other email as author) is "
778
+ "not the repo's identity, commits made with an AI agent (Co-Authored-By trailers, bot "
779
+ "emails) and the [*.trailers] policy. Default: the unpushed commits (as of the last fetch) "
780
+ "plus the newest pushed ones.",
781
+ epilog="--rewrite fixes only the unpushed offending commits, keeps every date and never runs rebase; "
782
+ "pushed commits are only reported. \"Pushed\" means reachable from a remote-tracking ref "
783
+ "(refs/remotes/*) as of the last fetch: commits published only through a tag, `git push <url>` "
784
+ "or Gerrit's refs/for/* count as unpushed, so fetch first. With commit.gpgsign set the new "
785
+ "commits are signed; notes are copied to them. With the guard installed (gitpro guard install) the commit-msg "
786
+ "hook applies the trailer policy at commit time; skip it once in your own shell with "
787
+ "GITPRO_SKIP=1 git commit ...")
788
+ sp.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
789
+ g = sp.add_mutually_exclusive_group()
790
+ g.add_argument("--range", metavar="A..B", help="audit this git rev-list range instead")
791
+ g.add_argument("--all", action="store_true", help="audit every commit reachable from HEAD")
792
+ w = sp.add_mutually_exclusive_group()
793
+ w.add_argument("--agents", action="store_true", help="only the agent / trailer audit")
794
+ w.add_argument("--authors", action="store_true", help="only the wrong-author audit")
795
+ sp.add_argument("--rewrite", action="store_true",
796
+ help="fix the unpushed wrong-author commits (asks first; writes an undo script)")
797
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
798
+ sp.add_argument("-v", "--verbose", action="store_true", help="list every commit, not the first ten")
799
+ sp.set_defaults(func=cmd_audit)
800
+ capability("audit", "audit.rewrite", "trailers")