gitpro 0.3.2__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {gitpro-0.3.2 → gitpro-0.3.4}/PKG-INFO +19 -2
  2. {gitpro-0.3.2 → gitpro-0.3.4}/README.md +15 -0
  3. {gitpro-0.3.2 → gitpro-0.3.4}/pyproject.toml +3 -1
  4. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/__init__.py +1 -1
  5. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/agents.py +24 -2
  6. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/args.py +38 -0
  7. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/cli.py +5 -0
  8. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/config.py +1 -1
  9. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/discover.py +1 -1
  10. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/guard.py +36 -8
  11. gitpro-0.3.4/src/gitpro/incident.py +133 -0
  12. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/lint.py +3 -0
  13. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/mcp.py +30 -0
  14. gitpro-0.3.4/src/gitpro/report.py +804 -0
  15. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/resolve.py +65 -15
  16. {gitpro-0.3.2 → gitpro-0.3.4}/.gitignore +0 -0
  17. {gitpro-0.3.2 → gitpro-0.3.4}/LICENSE +0 -0
  18. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/__main__.py +0 -0
  19. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/approval_policy.py +0 -0
  20. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/audit.py +0 -0
  21. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/change_contract.py +0 -0
  22. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/changes.py +0 -0
  23. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/clone.py +0 -0
  24. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/completion.py +0 -0
  25. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/devcontainer.py +0 -0
  26. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/doctor.py +0 -0
  27. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/envcmd.py +0 -0
  28. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/example.toml +0 -0
  29. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/files.py +0 -0
  30. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/forge.py +0 -0
  31. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/health.py +0 -0
  32. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/hot.py +0 -0
  33. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/jsonfile.py +0 -0
  34. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/jsonout.py +0 -0
  35. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/keys.py +0 -0
  36. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/lost.py +0 -0
  37. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/operation_contract.py +0 -0
  38. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/operation_io.py +0 -0
  39. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/operation_reconcile.py +0 -0
  40. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/operations.py +0 -0
  41. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/pin.py +0 -0
  42. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/plan.py +0 -0
  43. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/policy.py +0 -0
  44. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/prompt.py +0 -0
  45. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/render.py +0 -0
  46. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/scan.py +0 -0
  47. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/scanroots.py +0 -0
  48. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/setup.py +0 -0
  49. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/trailers.py +0 -0
  50. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/tui.py +0 -0
  51. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/undo.py +0 -0
  52. {gitpro-0.3.2 → gitpro-0.3.4}/src/gitpro/view.py +0 -0
@@ -1,10 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitpro
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Per-remote git identities (name, email, SSH key, commit signing) from one TOML file
5
- Project-URL: Homepage, https://github.com/Yantrakar/gitpro
5
+ Project-URL: Homepage, https://gitpro.dev
6
+ Project-URL: Source, https://github.com/Yantrakar/gitpro
6
7
  Project-URL: Documentation, https://github.com/Yantrakar/gitpro/blob/main/docs/guide.md
7
8
  Project-URL: Issues, https://github.com/Yantrakar/gitpro/issues
9
+ Project-URL: VS Code extension, https://marketplace.visualstudio.com/items?itemName=yantrakar.gitpro
8
10
  Author: Yantrakar, Sandeep Shelke
9
11
  License-Expression: MIT
10
12
  License-File: LICENSE
@@ -22,10 +24,16 @@ Provides-Extra: tui
22
24
  Requires-Dist: textual>=0.80; extra == 'tui'
23
25
  Description-Content-Type: text/markdown
24
26
 
27
+ <a href="https://gitpro.dev"><img src="https://gitpro.dev/icon.png" alt="gitpro" width="72" height="72"></a>
28
+
25
29
  # gitpro
26
30
 
31
+ <!-- mcp-name: io.github.Yantrakar/gitpro -->
32
+
27
33
  **The right Git identity, SSH key and signature for every repository.**
28
34
 
35
+ [gitpro.dev](https://gitpro.dev) · [PyPI](https://pypi.org/project/gitpro/) · [VS Code](https://marketplace.visualstudio.com/items?itemName=yantrakar.gitpro) · made by [Yantrakar](https://yantrakar.com)
36
+
29
37
  Work on a company repository, commit with your work email. Switch to a personal
30
38
  project, use your personal identity. Gitpro chooses by **remote URL**, not by
31
39
  where you happened to clone the repository.
@@ -98,6 +106,15 @@ optional operator-scoped preauthorization, and recoverable apply. MCP execution
98
106
  The detailed TOML, YAML and command examples live in the guide, not on the
99
107
  getting-started path.
100
108
 
109
+ ## Problems and questions
110
+
111
+ Run `gitpro report` to draft an issue with the versions and a redacted
112
+ `gitpro why`; it shows you the draft and opens nothing until you say yes.
113
+ Or open an issue at [github.com/Yantrakar/gitpro/issues](https://github.com/Yantrakar/gitpro/issues).
114
+ Report a security problem privately with `gitpro report --security`, as the
115
+ [security policy](https://github.com/Yantrakar/gitpro/security/policy) describes,
116
+ not in a public issue.
117
+
101
118
  ## License
102
119
 
103
120
  [MIT](LICENSE)
@@ -1,7 +1,13 @@
1
+ <a href="https://gitpro.dev"><img src="https://gitpro.dev/icon.png" alt="gitpro" width="72" height="72"></a>
2
+
1
3
  # gitpro
2
4
 
5
+ <!-- mcp-name: io.github.Yantrakar/gitpro -->
6
+
3
7
  **The right Git identity, SSH key and signature for every repository.**
4
8
 
9
+ [gitpro.dev](https://gitpro.dev) · [PyPI](https://pypi.org/project/gitpro/) · [VS Code](https://marketplace.visualstudio.com/items?itemName=yantrakar.gitpro) · made by [Yantrakar](https://yantrakar.com)
10
+
5
11
  Work on a company repository, commit with your work email. Switch to a personal
6
12
  project, use your personal identity. Gitpro chooses by **remote URL**, not by
7
13
  where you happened to clone the repository.
@@ -74,6 +80,15 @@ optional operator-scoped preauthorization, and recoverable apply. MCP execution
74
80
  The detailed TOML, YAML and command examples live in the guide, not on the
75
81
  getting-started path.
76
82
 
83
+ ## Problems and questions
84
+
85
+ Run `gitpro report` to draft an issue with the versions and a redacted
86
+ `gitpro why`; it shows you the draft and opens nothing until you say yes.
87
+ Or open an issue at [github.com/Yantrakar/gitpro/issues](https://github.com/Yantrakar/gitpro/issues).
88
+ Report a security problem privately with `gitpro report --security`, as the
89
+ [security policy](https://github.com/Yantrakar/gitpro/security/policy) describes,
90
+ not in a public issue.
91
+
77
92
  ## License
78
93
 
79
94
  [MIT](LICENSE)
@@ -26,9 +26,11 @@ classifiers = [
26
26
  tui = ["textual>=0.80"] # interactive scan --fix
27
27
 
28
28
  [project.urls]
29
- Homepage = "https://github.com/Yantrakar/gitpro"
29
+ Homepage = "https://gitpro.dev"
30
+ Source = "https://github.com/Yantrakar/gitpro"
30
31
  Documentation = "https://github.com/Yantrakar/gitpro/blob/main/docs/guide.md"
31
32
  Issues = "https://github.com/Yantrakar/gitpro/issues"
33
+ "VS Code extension" = "https://marketplace.visualstudio.com/items?itemName=yantrakar.gitpro"
32
34
 
33
35
  [project.scripts]
34
36
  gitpro = "gitpro.hot:main"
@@ -1,4 +1,4 @@
1
1
  """Per-remote git identities generated from one TOML file (see cli.py)."""
2
2
  from __future__ import annotations
3
3
 
4
- __version__ = "0.3.2"
4
+ __version__ = "0.3.4"
@@ -1113,6 +1113,9 @@ class Decision:
1113
1113
  cause: str
1114
1114
  fix: str
1115
1115
  repo: str
1116
+ why: str = "" # the denial record's reason code (not `reason`: that is the text below)
1117
+ codes: Tuple[str, ...] = () # problem codes of the repo's report
1118
+ action: str = "" # the git subcommand or program
1116
1119
 
1117
1120
  @property
1118
1121
  def reason(self) -> str:
@@ -1312,6 +1315,9 @@ def _judge_one(c: Call, rep, cfg, own: set, pushed_to: Optional[str] = None) ->
1312
1315
  top = str(rep.repo)
1313
1316
  what = _what(c)
1314
1317
  commits = c.prog == "git" and c.sub != "push"
1318
+ codes = tuple(p.code for p in rep.problems)
1319
+ action = c.sub if c.prog == "git" else c.prog
1320
+ why = ("no_identity" if exp.email is None else "committer" if c_bad else "author" if a_bad else "problem")
1315
1321
  if exp.email is None:
1316
1322
  from .resolve import redact_url
1317
1323
  url = redact_url(next(iter(rep.effective.remotes.values()), None)) or top
@@ -1329,9 +1335,10 @@ def _judge_one(c: Call, rep, cfg, own: set, pushed_to: Optional[str] = None) ->
1329
1335
  if c_bad and c_src == "effective" and pushed_to and not triggers:
1330
1336
  # the repo's other problems were computed for this remote's identity; they would mislead here
1331
1337
  return Decision(headline, f"this repo's commits use {committer or '(no email)'} and {pushed_to} belongs "
1332
- f"to {exp.id or '[default]'}", f"gitpro why {shlex.quote(top)}", top)
1338
+ f"to {exp.id or '[default]'}", f"gitpro why {shlex.quote(top)}", top,
1339
+ "pushed_to", codes, action)
1333
1340
  cause, fix = _cause(c, c_bad, c_src, committer, a_bad, a_src, author, rep, cfg, environ)
1334
- return Decision(headline, cause, fix, top)
1341
+ return Decision(headline, cause, fix, top, why, codes, action)
1335
1342
 
1336
1343
 
1337
1344
  def _command_text(c: Call, limit: int = 200) -> str:
@@ -1447,6 +1454,7 @@ def _check(args: argparse.Namespace, text: str) -> int:
1447
1454
  sys.stdout.flush()
1448
1455
  print(msg, file=sys.stderr)
1449
1456
  return 0
1457
+ _record_denial(args.agent, d)
1450
1458
  out, rc = adapter.deny(d)
1451
1459
  if out:
1452
1460
  sys.stdout.write(out + "\n")
@@ -1455,6 +1463,11 @@ def _check(args: argparse.Namespace, text: str) -> int:
1455
1463
  return rc
1456
1464
 
1457
1465
 
1466
+ def _record_denial(agent: str, d: Decision) -> None:
1467
+ from . import incident # lazy: stdlib only, but most checks never deny
1468
+ incident.record_denial("agent-check", agent, d.action, d.why, d.codes)
1469
+
1470
+
1458
1471
  def _fail_open(func: Callable[[], int]) -> int:
1459
1472
  try:
1460
1473
  return func()
@@ -1463,9 +1476,18 @@ def _fail_open(func: Callable[[], int]) -> int:
1463
1476
  except _ArgError as e:
1464
1477
  return _fail_open_msg(str(e))
1465
1478
  except Exception as e: # fail open: never stop the agent
1479
+ _record_crash(e)
1466
1480
  return _fail_open_msg(str(e) or type(e).__name__)
1467
1481
 
1468
1482
 
1483
+ def _record_crash(e: Exception) -> None:
1484
+ try:
1485
+ from . import incident
1486
+ incident.record_crash("agent-check", e)
1487
+ except Exception: # noqa: BLE001
1488
+ pass
1489
+
1490
+
1469
1491
  def _fail_open_msg(msg: str) -> int:
1470
1492
  try:
1471
1493
  _warn(msg)
@@ -11,6 +11,8 @@ so both routes accept the same arguments without importing cli.
11
11
  from __future__ import annotations
12
12
 
13
13
  import argparse
14
+ import os
15
+ import re
14
16
  import shlex
15
17
  import subprocess
16
18
  import sys
@@ -64,6 +66,40 @@ def fail(msg: str, args: object) -> int:
64
66
  return 2
65
67
 
66
68
 
69
+ _CMD_RE = re.compile(r"^[a-z][a-z0-9-]{0,30}$")
70
+
71
+
72
+ def _command_path(args: object) -> str:
73
+ """The command words of parsed args ("forge similar"), or "gitpro"."""
74
+ words = [getattr(args, "command", None)]
75
+ for key, val in sorted(vars(args).items()) if hasattr(args, "__dict__") else []:
76
+ if key != "command" and key.endswith(("_command", "_action")):
77
+ words.append(val)
78
+ return " ".join(w for w in words if isinstance(w, str) and _CMD_RE.match(w)) or "gitpro"
79
+
80
+
81
+ def _internal_error(args: object, exc: Exception) -> int:
82
+ """An unexpected exception: write the crash record, print two lines (the traceback only with
83
+ GITPRO_DEBUG=1), exit 2."""
84
+ from . import incident
85
+ command = _command_path(args)
86
+ incident.record_crash(command, exc)
87
+ name = type(exc).__qualname__
88
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_.]{0,80}$", name):
89
+ name = "Exception"
90
+ if os.environ.get("GITPRO_DEBUG") == "1":
91
+ import traceback
92
+ traceback.print_exception(type(exc), exc, exc.__traceback__)
93
+ if getattr(args, "json", False):
94
+ from . import jsonout
95
+ jsonout.error(getattr(args, "command", None) or "gitpro", f"internal error ({name})")
96
+ else:
97
+ print(f"✗ internal error ({name}): this is a gitpro bug", file=sys.stderr)
98
+ print("→ draft a report with: gitpro report --crash", file=sys.stderr)
99
+ return 2
100
+
101
+
102
+
67
103
  def guarded(func: Callable[[argparse.Namespace], int], args: argparse.Namespace) -> int:
68
104
  """func(args) with gitpro's error handling: ConfigError, CalledProcessError, OSError and UnicodeError
69
105
  become exit 2 through fail(); Ctrl-C / EOF exit 130."""
@@ -80,3 +116,5 @@ def guarded(func: Callable[[argparse.Namespace], int], args: argparse.Namespace)
80
116
  except (KeyboardInterrupt, EOFError):
81
117
  print(file=sys.stderr)
82
118
  return 130
119
+ except Exception as e: # noqa: BLE001 - a bug in gitpro: record it, say so, exit 2
120
+ return _internal_error(args, e)
@@ -14,6 +14,10 @@ first run on a new machine:
14
14
  gitpro keys # public keys to register with each account
15
15
  gitpro scan # every repo and the identity git uses there
16
16
 
17
+ problems and questions:
18
+ gitpro report --print # a redacted draft of a bug report; nothing is sent unless you say so
19
+ https://github.com/Yantrakar/gitpro/issues
20
+
17
21
  -- for developers (not part of --help) --
18
22
  CALLER_UMASK is the umask gitpro was started with. It is None until main() runs; main() saves it and
19
23
  then tightens the process umask to 077, so every file gitpro writes is private. A command that creates
@@ -58,6 +62,7 @@ EXTENSIONS = [
58
62
  "gitpro.operations", # versioned agent preflight and scoped execution
59
63
  "gitpro.policy", # 14: policy, team, check --policy
60
64
  "gitpro.prompt", # 16: prompt
65
+ "gitpro.report", # 19: report
61
66
  "gitpro.resolve", # 01: why
62
67
  "gitpro.scanroots", # 02: scan -c/--roots
63
68
  "gitpro.setup", # guided configuration
@@ -24,7 +24,7 @@ _LABEL = r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
24
24
  HOST_RE = re.compile(rf"^{_LABEL}(?:\.{_LABEL})*$")
25
25
  ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
26
26
  USER_RE = ID_RE
27
- OWNER_RE = re.compile(r"^[A-Za-z0-9*][A-Za-z0-9._*-]*(?:/[A-Za-z0-9*][A-Za-z0-9._*-]*)*$")
27
+ OWNER_RE = re.compile(r"^~?[A-Za-z0-9*][A-Za-z0-9._*-]*(?:/[A-Za-z0-9*][A-Za-z0-9._*-]*)*$") # ~: SourceHut users
28
28
  PATH_RE = re.compile(r"^(?:~/|/)[A-Za-z0-9._/@+-]+$")
29
29
  EMAIL_RE = re.compile(r'^[^@\s<>"]+@[^@\s<>"]+$')
30
30
  CONTROL_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
@@ -1228,7 +1228,7 @@ def render_toml(d: dict) -> str:
1228
1228
  notes.append(f"{where}: already in config" + (f" (identity {o['config_id']})" if o.get("config_id") else ""))
1229
1229
  continue
1230
1230
  if not OWNER_RE.match(o["owner"]):
1231
- notes.append(f"{where}: owners can hold only A-Z a-z 0-9 . _ - /; add it by hand if it is yours")
1231
+ notes.append(f"{where}: owners can hold only A-Z a-z 0-9 . _ - / and a leading ~; add it by hand if it is yours")
1232
1232
  continue
1233
1233
  rule = o.get("rule") or {}
1234
1234
  email = _top(o.get("emails"), _usable_email)[0]
@@ -189,6 +189,7 @@ def _check(name: str, argv: List[str], stdin: Optional[str], repo: Optional[Path
189
189
  except KeyboardInterrupt:
190
190
  raise
191
191
  except Exception as e: # noqa: BLE001
192
+ _record_crash(f"guard {name}", e)
192
193
  _warn(name, f"⚠ gitpro {spec}: {e}; not blocking")
193
194
  continue
194
195
  if rc == BLOCK:
@@ -227,8 +228,13 @@ def _where(exp) -> str:
227
228
  return url or "this repo"
228
229
 
229
230
 
230
- def _mismatch(ctx: Context, head: str, lines: List[str], skip: str) -> int:
231
+ _RECORD = True # False while `guard test` simulates a hook: nothing was denied
232
+
233
+
234
+ def _mismatch(ctx: Context, head: str, lines: List[str], skip: str, reason: str = "problem") -> int:
231
235
  warn = ctx.cfg.guard.on_mismatch != "block"
236
+ if not warn and _RECORD:
237
+ _record_denial(ctx, reason)
232
238
  ctx.say(("⚠" if warn else "✗") + f" gitpro: {head}")
233
239
  for line in lines:
234
240
  ctx.say(" " + line)
@@ -238,6 +244,23 @@ def _mismatch(ctx: Context, head: str, lines: List[str], skip: str) -> int:
238
244
  return BLOCK
239
245
 
240
246
 
247
+ def _record_crash(command: str, e: Exception) -> None:
248
+ try:
249
+ from . import incident
250
+ incident.record_crash(command, e)
251
+ except Exception: # noqa: BLE001
252
+ pass
253
+
254
+
255
+ def _record_denial(ctx: Context, reason: str) -> None:
256
+ try:
257
+ from . import incident
258
+ codes = tuple(p.code for p in getattr(ctx.report, "problems", ()) or ())
259
+ incident.record_denial("guard", None, ctx.hook, reason, codes)
260
+ except Exception: # noqa: BLE001 - recording never changes the hook's answer
261
+ pass
262
+
263
+
241
264
  def _id_of(cfg, email: str) -> str:
242
265
  for i in cfg.identities:
243
266
  if _same(i.email, email):
@@ -269,14 +292,14 @@ def identity_commit(ctx: Context) -> int:
269
292
  lines = [f"cause: git resolves user.email to {ctx.committer_email} here",
270
293
  f"fix: {PROG} why {shlex.quote(str(ctx.repo))}"]
271
294
  return _mismatch(ctx, f"this commit would be committed as {ctx.committer_email} but {_where(exp)} "
272
- f"belongs to {_belongs(exp)}", lines, "commit")
295
+ f"belongs to {_belongs(exp)}", lines, "commit", "committer")
273
296
  a = ctx.author_email
274
297
  if a and bad_author:
275
298
  lines = [f"cause: author is your {_id_of(ctx.cfg, a)} email",
276
299
  f"fix: unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL, or commit with "
277
300
  f"--author={shlex.quote(f'{exp.name} <{exp.email}>')}"]
278
301
  return _mismatch(ctx, f"this commit would be authored as {a} but {_where(exp)} belongs to "
279
- f"{_belongs(exp)}", lines, "commit")
302
+ f"{_belongs(exp)}", lines, "commit", "author")
280
303
  return 0
281
304
 
282
305
 
@@ -325,7 +348,7 @@ def identity_push(ctx: Context) -> int:
325
348
  "then git commit --amend --no-edit on each)")
326
349
  head = (f"{n} commit{'s' * (n != 1)} to {_where(exp)} {'are' if n != 1 else 'is'} committed as {emails}; "
327
350
  f"this remote belongs to {exp.id or '[default]'} <{exp.email}>")
328
- return _mismatch(ctx, head, lines, "push")
351
+ return _mismatch(ctx, head, lines, "push", "push")
329
352
 
330
353
 
331
354
  def _audit_available() -> bool:
@@ -598,10 +621,15 @@ def _check_strict(repo: Path) -> int:
598
621
  ctx = Context("pre-commit", top, (), "", cfg, report, _ident_email(top, "COMMITTER", os.environ),
599
622
  _ident_email(top, "AUTHOR", os.environ), _say)
600
623
  refused = False
601
- for spec in HOOKS["pre-commit"]:
602
- fn = _entry(spec)
603
- if fn is not None and fn(ctx) == BLOCK:
604
- refused = True
624
+ global _RECORD
625
+ _RECORD = False
626
+ try:
627
+ for spec in HOOKS["pre-commit"]:
628
+ fn = _entry(spec)
629
+ if fn is not None and fn(ctx) == BLOCK:
630
+ refused = True
631
+ finally:
632
+ _RECORD = True
605
633
  return BLOCK if refused and cfg.guard.on_mismatch == "block" else 0
606
634
 
607
635
 
@@ -0,0 +1,133 @@
1
+ """Incident records for `gitpro report` (spec 19, section 8): the last crash and the last denials.
2
+
3
+ Stdlib only, so the hot hooks can import it. Best effort: a record that cannot be written is dropped
4
+ silently, because a failing hook must behave exactly as it did before. A record holds fixed fields
5
+ only: never an exception message, a local variable, an argument or a path outside the gitpro package.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Sequence
15
+
16
+ from . import __version__
17
+
18
+ PROG = "gitpro"
19
+ CRASH = "last-crash.json"
20
+ DENIALS = "denials.jsonl"
21
+ KEEP = 20 # denials kept
22
+ MAX_FRAMES = 30
23
+ REASONS = ("committer", "author", "no_identity", "pushed_to", "problem", "push")
24
+ SOURCES = ("guard", "agent-check")
25
+ _TYPE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]{0,80}$")
26
+ _FUNC_RE = re.compile(r"^[A-Za-z_<][A-Za-z0-9_<>]{0,80}$")
27
+ _COMMAND_RE = re.compile(r"^[a-z][a-z0-9 _.-]{0,60}$")
28
+ _WORD_RE = re.compile(r"^[A-Za-z0-9_.-]{1,40}$")
29
+ _CODE_RE = re.compile(r"^[a-z_]{1,32}$")
30
+
31
+
32
+ def state_dir() -> Path:
33
+ """The same directory as undo.state_dir(): $XDG_STATE_HOME/gitpro or ~/.local/state/gitpro, 0700."""
34
+ base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local/state")
35
+ path = Path(base) / PROG
36
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
37
+ return path
38
+
39
+
40
+ def _now() -> str:
41
+ return time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime())
42
+
43
+
44
+ def _write(path: Path, text: str) -> None:
45
+ """Atomically replace path with text, mode 0600."""
46
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
47
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
48
+ try:
49
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
50
+ f.write(text)
51
+ os.replace(tmp, path)
52
+ except BaseException:
53
+ try:
54
+ tmp.unlink()
55
+ except OSError:
56
+ pass
57
+ raise
58
+
59
+
60
+ def _frames(tb: Any) -> "tuple[List[list], int]":
61
+ """(frames inside the gitpro package as [gitpro/<relpath>, line, func], count of the others)."""
62
+ import traceback
63
+ pkg = Path(__file__).resolve().parent
64
+ inside: List[list] = []
65
+ outside = 0
66
+ for frame, line in traceback.walk_tb(tb):
67
+ try:
68
+ rel = Path(frame.f_code.co_filename).resolve().relative_to(pkg)
69
+ except (ValueError, OSError):
70
+ outside += 1
71
+ continue
72
+ name = frame.f_code.co_name
73
+ inside.append([f"{PROG}/{rel.as_posix()}", int(line), name if _FUNC_RE.match(name) else "?"])
74
+ return inside[-MAX_FRAMES:], outside
75
+
76
+
77
+ def record_crash(command: str, exc: BaseException) -> None:
78
+ """Write last-crash.json for an unexpected exception raised while running `command`."""
79
+ try:
80
+ frames, outside = _frames(exc.__traceback__)
81
+ name = type(exc).__qualname__
82
+ rec = {"time": _now(), "version": __version__,
83
+ "command": command if _COMMAND_RE.match(command or "") else "unknown",
84
+ "type": name if _TYPE_RE.match(name) else "Exception",
85
+ "frames": frames, "outside": outside}
86
+ _write(state_dir() / CRASH, json.dumps(rec) + "\n")
87
+ except Exception: # noqa: BLE001 - recording must never change what the caller does
88
+ pass
89
+
90
+
91
+ def record_denial(source: str, agent: Optional[str], action: str, reason: str,
92
+ codes: Sequence[str] = ()) -> None:
93
+ """Append one denial to denials.jsonl, keeping the last KEEP."""
94
+ try:
95
+ if source not in SOURCES:
96
+ return
97
+ rec = {"time": _now(), "version": __version__, "source": source,
98
+ "agent": agent if agent and _WORD_RE.match(agent) else None,
99
+ "action": action if _WORD_RE.match(action or "") else "other",
100
+ "reason": reason if reason in REASONS else "problem",
101
+ "codes": [c for c in codes if isinstance(c, str) and _CODE_RE.match(c)]}
102
+ path = state_dir() / DENIALS
103
+ old = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
104
+ lines = [ln for ln in old if ln.strip()][-(KEEP - 1):] + [json.dumps(rec)]
105
+ _write(path, "\n".join(lines) + "\n")
106
+ except Exception: # noqa: BLE001
107
+ pass
108
+
109
+
110
+ def read_crash() -> Optional[Dict[str, Any]]:
111
+ """The parsed last-crash.json, or None. Fields are not validated here; the reader must."""
112
+ try:
113
+ data = json.loads((state_dir() / CRASH).read_text(encoding="utf-8"))
114
+ except (OSError, ValueError):
115
+ return None
116
+ return data if isinstance(data, dict) else None
117
+
118
+
119
+ def read_denials() -> List[Dict[str, Any]]:
120
+ """The parsed records of denials.jsonl, oldest first (unparsable lines skipped)."""
121
+ out: List[Dict[str, Any]] = []
122
+ try:
123
+ text = (state_dir() / DENIALS).read_text(encoding="utf-8")
124
+ except OSError:
125
+ return out
126
+ for line in text.splitlines():
127
+ try:
128
+ rec = json.loads(line)
129
+ except ValueError:
130
+ continue
131
+ if isinstance(rec, dict):
132
+ out.append(rec)
133
+ return out
@@ -7,11 +7,14 @@ import subprocess
7
7
  from . import keys, render
8
8
  from .config import Default, Identity
9
9
  from .plan import Env
10
+ from .resolve import is_codecommit
10
11
 
11
12
 
12
13
  def warnings(d: Default, ids: list[Identity], env: Env) -> list[str]:
13
14
  out = []
14
15
  for i in ids:
16
+ if is_codecommit(i.host) and i.ssh_user == "git" and (i.ssh_key or i.ssh_alias):
17
+ out.append(f"identity {i.id!r}: CodeCommit's SSH user is the SSH key ID, not git; set ssh_user to it")
15
18
  if i.ssh_alias and i.rewrite:
16
19
  out += [f"identity {i.id!r}: owner {o!r} is a glob; insteadOf needs a literal prefix, so "
17
20
  f"its URLs aren't rewritten to {i.ssh_alias}" for o in i.owners if "*" in o]
@@ -409,6 +409,23 @@ def tool_identities(args: dict, ctx: Ctx) -> dict:
409
409
  "clone_dir": i.clone_dir, "sign": config.signs(i, d)} for i in cfg.identities]}
410
410
 
411
411
 
412
+ def tool_draft_report(args: dict, ctx: Ctx) -> dict:
413
+ """A redacted bug report and its prefilled issue url; nothing is sent, opened or recorded."""
414
+ from . import report
415
+ kind = args.get("kind", "bug")
416
+ repo = _repo(args["path"]) if "path" in args else None
417
+ try:
418
+ d = report.build_draft(kind, message=args.get("message"), repo=repo, config=ctx.cfg_path)
419
+ except report.ReportError as e:
420
+ raise ToolError(str(e)) from None
421
+ if d.hits:
422
+ kinds = ", ".join(dict.fromkeys(h["kind"] for h in d.hits))
423
+ where = ", ".join(dict.fromkeys(h["section"] for h in d.hits))
424
+ raise ToolError(f"no draft: it would contain {kinds} in {where}; nothing was shown or sent")
425
+ return {"ok": True, "command": "report", "title": d.title, "body": d.body, "url": d.url,
426
+ "security_url": report.ADVISORY_URL, "truncated": d.truncated}
427
+
428
+
412
429
  # ---------------------------------------------------------------- the registry
413
430
 
414
431
  def _schema(props: dict, required: Tuple[str, ...] = ()) -> dict:
@@ -487,6 +504,14 @@ TOOLS: Dict[str, Tool] = {t.name: t for t in (
487
504
  tool_audit, "gitpro.audit"),
488
505
  Tool("identities", "The configured identities (name, email, host, owners, forge accounts, signing); no key "
489
506
  "contents, no extra git settings.", _schema({}), tool_identities),
507
+ Tool("draft_report", "Draft a redacted gitpro bug report (versions, a redacted why, the last crash or "
508
+ "denial) and the prefilled GitHub issue URL. It never sends or opens anything. Show the user the draft "
509
+ "and the URL and let them decide; for a security problem, give them security_url instead of an issue.",
510
+ _schema({"path": PATH, "kind": {"type": "string", "enum": ["bug", "crash", "denial"],
511
+ "description": "what to report (default: bug)"},
512
+ "message": {"type": "string", "minLength": 1, "maxLength": 2000,
513
+ "description": "what happened (redacted before it is used)"}}),
514
+ tool_draft_report),
490
515
  Tool("preflight", "Versioned operation decision and state token for commit, push or GitHub PR creation. "
491
516
  "Read-only; online=true explicitly permits account/destination reads. Ready is not authorization.",
492
517
  _schema({"request": REQUEST_SCHEMA, "online": {"type": "boolean"}}, ("request",)), tool_preflight),
@@ -695,6 +720,11 @@ class Server:
695
720
  tb = traceback.format_exc()
696
721
  sys.stderr.write(tb)
697
722
  self.note("!", tb)
723
+ try:
724
+ from . import incident
725
+ incident.record_crash(f"mcp {tool.name}", e)
726
+ except Exception: # noqa: BLE001 - recording never changes the answer
727
+ pass
698
728
  return self.failed(f"internal error: {type(e).__name__}")
699
729
 
700
730
  def result(self, obj: dict) -> dict: