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/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
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")
|