gitpro 0.3.3__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {gitpro-0.3.3 → gitpro-0.4.0}/PKG-INFO +38 -23
  2. {gitpro-0.3.3 → gitpro-0.4.0}/README.md +37 -22
  3. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/__init__.py +1 -1
  4. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/agents.py +48 -4
  5. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/args.py +48 -0
  6. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/audit.py +17 -6
  7. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/cli.py +248 -26
  8. gitpro-0.4.0/src/gitpro/commitcheck.py +113 -0
  9. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/completion.py +2 -2
  10. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/config.py +3 -3
  11. gitpro-0.4.0/src/gitpro/deprecation.py +36 -0
  12. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/discover.py +40 -9
  13. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/guard.py +44 -9
  14. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/hot.py +2 -1
  15. gitpro-0.4.0/src/gitpro/incident.py +133 -0
  16. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/jsonfile.py +13 -4
  17. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/jsonout.py +3 -0
  18. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/keys.py +3 -2
  19. gitpro-0.4.0/src/gitpro/learn.py +116 -0
  20. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/mcp.py +118 -119
  21. gitpro-0.4.0/src/gitpro/pin.py +166 -0
  22. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/plan.py +11 -6
  23. gitpro-0.4.0/src/gitpro/report.py +804 -0
  24. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/resolve.py +149 -19
  25. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/scan.py +117 -9
  26. gitpro-0.4.0/src/gitpro/setup.py +668 -0
  27. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/view.py +22 -3
  28. gitpro-0.3.3/src/gitpro/pin.py +0 -89
  29. gitpro-0.3.3/src/gitpro/setup.py +0 -260
  30. {gitpro-0.3.3 → gitpro-0.4.0}/.gitignore +0 -0
  31. {gitpro-0.3.3 → gitpro-0.4.0}/LICENSE +0 -0
  32. {gitpro-0.3.3 → gitpro-0.4.0}/pyproject.toml +0 -0
  33. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/__main__.py +0 -0
  34. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/approval_policy.py +0 -0
  35. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/change_contract.py +0 -0
  36. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/changes.py +0 -0
  37. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/clone.py +0 -0
  38. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/devcontainer.py +0 -0
  39. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/doctor.py +0 -0
  40. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/envcmd.py +0 -0
  41. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/example.toml +0 -0
  42. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/files.py +0 -0
  43. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/forge.py +0 -0
  44. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/health.py +0 -0
  45. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/lint.py +0 -0
  46. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/lost.py +0 -0
  47. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/operation_contract.py +0 -0
  48. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/operation_io.py +0 -0
  49. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/operation_reconcile.py +0 -0
  50. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/operations.py +0 -0
  51. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/policy.py +0 -0
  52. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/prompt.py +0 -0
  53. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/render.py +0 -0
  54. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/scanroots.py +0 -0
  55. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/trailers.py +0 -0
  56. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/tui.py +0 -0
  57. {gitpro-0.3.3 → gitpro-0.4.0}/src/gitpro/undo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitpro
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Per-remote git identities (name, email, SSH key, commit signing) from one TOML file
5
5
  Project-URL: Homepage, https://gitpro.dev
6
6
  Project-URL: Source, https://github.com/Yantrakar/gitpro
@@ -48,26 +48,38 @@ Install from [PyPI](https://pypi.org/project/gitpro/) with pipx.
48
48
 
49
49
  ```sh
50
50
  pipx install gitpro
51
- gitpro setup --write
51
+ gitpro setup
52
52
  ```
53
53
 
54
- The wizard helps you choose identities, Git hosts and owners, existing SSH keys,
55
- and optional signing. **No TOML editing is needed for basic setup.** Review the
56
- configuration before saving; nothing is applied to Git or SSH yet.
57
-
58
- ```sh
59
- gitpro plan # see exactly what would change
60
- gitpro apply -y # explicitly apply the reviewed changes
54
+ Setup offers to look through your repos and proposes your identities. Accept,
55
+ edit or answer the questions yourself; **no TOML editing is needed.** One
56
+ preview shows everything, including the commit guard and, with Claude Code, its
57
+ hook and MCP server. One yes applies it all. Setup never changes your repos.
58
+
59
+ ```mermaid
60
+ flowchart LR
61
+ S["gitpro setup"] --> HC{"Config <br/>exists?"}
62
+ HC -->|no| L["Read your repos, <br/>SSH config and <br/>editor recents, <br/>if you agree"]
63
+ L --> Q["Propose <br/>identities; you <br/>accept or edit"]
64
+ Q --> I
65
+ HC -->|yes| I["Inspect config, <br/>Git and SSH files, <br/>keys, guard, <br/>agent hook, MCP"]
66
+ I -->|all current| OK["Nothing <br/>to do"]
67
+ I -->|something to do| PV["One preview, <br/>each part with <br/>its undo"]
68
+ PV --> ASK{"Apply <br/>all?"}
69
+ ASK -->|no| NONE["Nothing <br/>written"]
70
+ ASK -->|yes| AP["Write it, print <br/>the public keys <br/>to register"]
71
+ AP --> NX["Next: <br/>gitpro scan"]
61
72
  ```
62
73
 
63
- Then, inside a repository:
74
+ Then:
64
75
 
65
76
  ```sh
66
- gitpro why # see the expected identity and anything overriding it
77
+ gitpro why # inside a repository: the expected identity and anything overriding it
78
+ gitpro scan # repos that need a fix, with the command for each
67
79
  ```
68
80
 
69
- Already have a config? Run `gitpro setup --write` to add another identity without losing
70
- your existing settings or comments. Prefer editing files? The
81
+ Already have a config? `gitpro setup` brings this machine up to date with it.
82
+ `gitpro setup -n` only shows what is left to do. Prefer editing files? The
71
83
  [user guide](docs/guide.md#configuration) covers the full TOML format.
72
84
 
73
85
  ## What you get
@@ -79,20 +91,17 @@ your existing settings or comments. Prefer editing files? The
79
91
 
80
92
  Gitpro generates ordinary Git and SSH configuration; it needs no background
81
93
  service. Changes can be previewed, existing hand-written files are backed up,
82
- and key creation or uploads are never part of the setup wizard.
94
+ and key uploads are never part of setup.
83
95
 
84
96
  ## Using VS Code?
85
97
 
86
- Run **gitpro: Set Up Identities** from the Command Palette to open the same wizard
87
- in a terminal. The extension also shows the current identity and helps find
88
- repository-level overrides. [Install the extension](vscode/README.md).
98
+ The extension shows the current identity and helps find repository-level
99
+ overrides. It runs the same commands as the terminal. [Install the extension](vscode/README.md).
89
100
 
90
101
  ## Go further when you need to
91
102
 
92
- For agents, [the versioned workflow](docs/agent-workflows.md) provides structured
93
- preflight, identity-scoped execution and durable idempotency for commits, pushes
94
- and GitHub PRs. Persistent changes use exact proposals, human approval by default,
95
- optional operator-scoped preauthorization, and recoverable apply. MCP execution is opt-in.
103
+ AI agents get the same answers through the MCP server and the Claude Code hook:
104
+ see [agent workflows](docs/agent-workflows.md).
96
105
 
97
106
  | I want to… | Start here |
98
107
  |---|---|
@@ -103,8 +112,14 @@ optional operator-scoped preauthorization, and recoverable apply. MCP execution
103
112
  | Look up a command or undo changes | [Command reference](docs/guide.md#commands) · [Undo](docs/guide.md#undo-everything) |
104
113
  | Troubleshoot or contribute | [Troubleshooting](docs/guide.md#troubleshooting) · [Development](docs/guide.md#development) |
105
114
 
106
- The detailed TOML, YAML and command examples live in the guide, not on the
107
- getting-started path.
115
+ ## Problems and questions
116
+
117
+ Run `gitpro report` to draft an issue with the versions and a redacted
118
+ `gitpro why`; it shows you the draft and opens nothing until you say yes.
119
+ Or open an issue at [github.com/Yantrakar/gitpro/issues](https://github.com/Yantrakar/gitpro/issues).
120
+ Report a security problem privately with `gitpro report --security`, as the
121
+ [security policy](https://github.com/Yantrakar/gitpro/security/policy) describes,
122
+ not in a public issue.
108
123
 
109
124
  ## License
110
125
 
@@ -22,26 +22,38 @@ Install from [PyPI](https://pypi.org/project/gitpro/) with pipx.
22
22
 
23
23
  ```sh
24
24
  pipx install gitpro
25
- gitpro setup --write
25
+ gitpro setup
26
26
  ```
27
27
 
28
- The wizard helps you choose identities, Git hosts and owners, existing SSH keys,
29
- and optional signing. **No TOML editing is needed for basic setup.** Review the
30
- configuration before saving; nothing is applied to Git or SSH yet.
31
-
32
- ```sh
33
- gitpro plan # see exactly what would change
34
- gitpro apply -y # explicitly apply the reviewed changes
28
+ Setup offers to look through your repos and proposes your identities. Accept,
29
+ edit or answer the questions yourself; **no TOML editing is needed.** One
30
+ preview shows everything, including the commit guard and, with Claude Code, its
31
+ hook and MCP server. One yes applies it all. Setup never changes your repos.
32
+
33
+ ```mermaid
34
+ flowchart LR
35
+ S["gitpro setup"] --> HC{"Config <br/>exists?"}
36
+ HC -->|no| L["Read your repos, <br/>SSH config and <br/>editor recents, <br/>if you agree"]
37
+ L --> Q["Propose <br/>identities; you <br/>accept or edit"]
38
+ Q --> I
39
+ HC -->|yes| I["Inspect config, <br/>Git and SSH files, <br/>keys, guard, <br/>agent hook, MCP"]
40
+ I -->|all current| OK["Nothing <br/>to do"]
41
+ I -->|something to do| PV["One preview, <br/>each part with <br/>its undo"]
42
+ PV --> ASK{"Apply <br/>all?"}
43
+ ASK -->|no| NONE["Nothing <br/>written"]
44
+ ASK -->|yes| AP["Write it, print <br/>the public keys <br/>to register"]
45
+ AP --> NX["Next: <br/>gitpro scan"]
35
46
  ```
36
47
 
37
- Then, inside a repository:
48
+ Then:
38
49
 
39
50
  ```sh
40
- gitpro why # see the expected identity and anything overriding it
51
+ gitpro why # inside a repository: the expected identity and anything overriding it
52
+ gitpro scan # repos that need a fix, with the command for each
41
53
  ```
42
54
 
43
- Already have a config? Run `gitpro setup --write` to add another identity without losing
44
- your existing settings or comments. Prefer editing files? The
55
+ Already have a config? `gitpro setup` brings this machine up to date with it.
56
+ `gitpro setup -n` only shows what is left to do. Prefer editing files? The
45
57
  [user guide](docs/guide.md#configuration) covers the full TOML format.
46
58
 
47
59
  ## What you get
@@ -53,20 +65,17 @@ your existing settings or comments. Prefer editing files? The
53
65
 
54
66
  Gitpro generates ordinary Git and SSH configuration; it needs no background
55
67
  service. Changes can be previewed, existing hand-written files are backed up,
56
- and key creation or uploads are never part of the setup wizard.
68
+ and key uploads are never part of setup.
57
69
 
58
70
  ## Using VS Code?
59
71
 
60
- Run **gitpro: Set Up Identities** from the Command Palette to open the same wizard
61
- in a terminal. The extension also shows the current identity and helps find
62
- repository-level overrides. [Install the extension](vscode/README.md).
72
+ The extension shows the current identity and helps find repository-level
73
+ overrides. It runs the same commands as the terminal. [Install the extension](vscode/README.md).
63
74
 
64
75
  ## Go further when you need to
65
76
 
66
- For agents, [the versioned workflow](docs/agent-workflows.md) provides structured
67
- preflight, identity-scoped execution and durable idempotency for commits, pushes
68
- and GitHub PRs. Persistent changes use exact proposals, human approval by default,
69
- optional operator-scoped preauthorization, and recoverable apply. MCP execution is opt-in.
77
+ AI agents get the same answers through the MCP server and the Claude Code hook:
78
+ see [agent workflows](docs/agent-workflows.md).
70
79
 
71
80
  | I want to… | Start here |
72
81
  |---|---|
@@ -77,8 +86,14 @@ optional operator-scoped preauthorization, and recoverable apply. MCP execution
77
86
  | Look up a command or undo changes | [Command reference](docs/guide.md#commands) · [Undo](docs/guide.md#undo-everything) |
78
87
  | Troubleshoot or contribute | [Troubleshooting](docs/guide.md#troubleshooting) · [Development](docs/guide.md#development) |
79
88
 
80
- The detailed TOML, YAML and command examples live in the guide, not on the
81
- getting-started path.
89
+ ## Problems and questions
90
+
91
+ Run `gitpro report` to draft an issue with the versions and a redacted
92
+ `gitpro why`; it shows you the draft and opens nothing until you say yes.
93
+ Or open an issue at [github.com/Yantrakar/gitpro/issues](https://github.com/Yantrakar/gitpro/issues).
94
+ Report a security problem privately with `gitpro report --security`, as the
95
+ [security policy](https://github.com/Yantrakar/gitpro/security/policy) describes,
96
+ not in a public issue.
82
97
 
83
98
  ## License
84
99
 
@@ -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.3"
4
+ __version__ = "0.4.0"
@@ -34,7 +34,7 @@ MAX_SCAN = 1024 * 1024 # the regex scan reads at most this much
34
34
  MAX_FOR = 20 # `for x in w1 .. wN` with more words: the body is judged once, x unknown
35
35
  MAX_VALUE = 4096 # a longer expanded $VAR value counts as unknown
36
36
  ALIAS_TIMEOUT = 3 # seconds for the `git config --get alias.X` lookup on the hook path
37
- TRIGGERS = ("override", "pin_stale", "no_identity", "env_override")
37
+ TRIGGERS = ("ambiguous", "override", "pin_stale", "no_identity", "env_override")
38
38
  SKIP_ENV = "GITPRO_SKIP"
39
39
  BYPASS_HELP = (f"{SKIP_ENV}=1 in the environment the agent starts with turns the check off for that session; "
40
40
  "[agents] hook = \"warn\" or \"off\" in the config changes it for good. The deny message never "
@@ -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,10 +1315,15 @@ 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
1318
1324
  headline = f"no identity matches this repo ({url}) and [default] has no email"
1325
+ elif any(p.code == "ambiguous" for p in triggers):
1326
+ headline = f"this {what} runs in a repo whose remotes belong to different identities"
1319
1327
  elif c_bad or a_bad:
1320
1328
  email, verb = (committer, "committed") if c_bad else (author, "authored")
1321
1329
  email = email or "(no email)"
@@ -1329,9 +1337,10 @@ def _judge_one(c: Call, rep, cfg, own: set, pushed_to: Optional[str] = None) ->
1329
1337
  if c_bad and c_src == "effective" and pushed_to and not triggers:
1330
1338
  # the repo's other problems were computed for this remote's identity; they would mislead here
1331
1339
  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)
1340
+ f"to {exp.id or '[default]'}", f"gitpro why {shlex.quote(top)}", top,
1341
+ "pushed_to", codes, action)
1333
1342
  cause, fix = _cause(c, c_bad, c_src, committer, a_bad, a_src, author, rep, cfg, environ)
1334
- return Decision(headline, cause, fix, top)
1343
+ return Decision(headline, cause, fix, top, why, codes, action)
1335
1344
 
1336
1345
 
1337
1346
  def _command_text(c: Call, limit: int = 200) -> str:
@@ -1447,6 +1456,7 @@ def _check(args: argparse.Namespace, text: str) -> int:
1447
1456
  sys.stdout.flush()
1448
1457
  print(msg, file=sys.stderr)
1449
1458
  return 0
1459
+ _record_denial(args.agent, d)
1450
1460
  out, rc = adapter.deny(d)
1451
1461
  if out:
1452
1462
  sys.stdout.write(out + "\n")
@@ -1455,6 +1465,11 @@ def _check(args: argparse.Namespace, text: str) -> int:
1455
1465
  return rc
1456
1466
 
1457
1467
 
1468
+ def _record_denial(agent: str, d: Decision) -> None:
1469
+ from . import incident # lazy: stdlib only, but most checks never deny
1470
+ incident.record_denial("agent-check", agent, d.action, d.why, d.codes)
1471
+
1472
+
1458
1473
  def _fail_open(func: Callable[[], int]) -> int:
1459
1474
  try:
1460
1475
  return func()
@@ -1463,9 +1478,18 @@ def _fail_open(func: Callable[[], int]) -> int:
1463
1478
  except _ArgError as e:
1464
1479
  return _fail_open_msg(str(e))
1465
1480
  except Exception as e: # fail open: never stop the agent
1481
+ _record_crash(e)
1466
1482
  return _fail_open_msg(str(e) or type(e).__name__)
1467
1483
 
1468
1484
 
1485
+ def _record_crash(e: Exception) -> None:
1486
+ try:
1487
+ from . import incident
1488
+ incident.record_crash("agent-check", e)
1489
+ except Exception: # noqa: BLE001
1490
+ pass
1491
+
1492
+
1469
1493
  def _fail_open_msg(msg: str) -> int:
1470
1494
  try:
1471
1495
  _warn(msg)
@@ -1790,11 +1814,31 @@ def _edit(args: argparse.Namespace, removing: bool) -> int:
1790
1814
  view.step("added .claude/settings.local.json to .git/info/exclude so it is not committed")
1791
1815
  view.ok(f"removed the gitpro hook from {shown}" if removing else
1792
1816
  f"installed; {title} picks it up on its next session")
1793
- restores = f"restores from {undo.tilde(res.backup)}" if res.backup else f"removes {shown}"
1817
+ restores = f"restores from {undo.tilde(res.backup)}" if res.backup else f"removes {shown} unless it changed since"
1794
1818
  print(f"↩ undo: sh {undo.tilde(res.undo)} ({restores})")
1795
1819
  return 0
1796
1820
 
1797
1821
 
1822
+ HOOK_AGENTS = ("claude", "cursor", "gemini")
1823
+
1824
+
1825
+ def plugin_enabled(home: Path) -> Optional[str]:
1826
+ """The enabled gitpro plugin in ~/.claude/settings.json (it ships the hook and the MCP server), or None."""
1827
+ t = target("claude", "user", home, None)
1828
+ return _plugin(_read_lenient(t.path)) if t.path.is_file() else None
1829
+
1830
+
1831
+ def hook_change(agent: str, home: Path) -> Optional[str]:
1832
+ """For gitpro setup: what `agent install --agent AGENT` would do at user scope ("create PATH" or
1833
+ "update PATH"), or None when the hook is in place. ConfigError for a file gitpro must not edit."""
1834
+ from . import undo
1835
+ t = target(agent, "user", home, gitpro_path())
1836
+ obj = _precheck(t.path, t, _snippet(t))
1837
+ if obj is not None and add_hook(copy.deepcopy(obj), t) == obj:
1838
+ return None
1839
+ return f"{'update' if obj is not None else 'create'} {undo.tilde(t.path)}"
1840
+
1841
+
1798
1842
  def install(args: argparse.Namespace) -> int:
1799
1843
  """`gitpro agent install`."""
1800
1844
  return _edit(args, removing=False)
@@ -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
@@ -41,7 +43,17 @@ def confirm() -> argparse.ArgumentParser:
41
43
  return p
42
44
 
43
45
 
46
+ def dry_run(p: argparse.ArgumentParser) -> None:
47
+ """-n/--dry-run for a command that already previews without -y (pin, scan, audit; spec 22 R4)."""
48
+ p.add_argument("-n", "--dry-run", action="store_true", help="show only, change nothing (as without -y)")
49
+
50
+
44
51
  def preview_only(args: object) -> bool:
52
+ if getattr(args, "dry_run", False):
53
+ if getattr(args, "yes", False):
54
+ from .config import ConfigError
55
+ raise ConfigError("choose one: -n shows the change, -y makes it")
56
+ return True
45
57
  return not (getattr(args, "yes", False) or getattr(args, "write", False))
46
58
 
47
59
 
@@ -64,6 +76,40 @@ def fail(msg: str, args: object) -> int:
64
76
  return 2
65
77
 
66
78
 
79
+ _CMD_RE = re.compile(r"^[a-z][a-z0-9-]{0,30}$")
80
+
81
+
82
+ def _command_path(args: object) -> str:
83
+ """The command words of parsed args ("forge similar"), or "gitpro"."""
84
+ words = [getattr(args, "command", None)]
85
+ for key, val in sorted(vars(args).items()) if hasattr(args, "__dict__") else []:
86
+ if key != "command" and key.endswith(("_command", "_action")):
87
+ words.append(val)
88
+ return " ".join(w for w in words if isinstance(w, str) and _CMD_RE.match(w)) or "gitpro"
89
+
90
+
91
+ def _internal_error(args: object, exc: Exception) -> int:
92
+ """An unexpected exception: write the crash record, print two lines (the traceback only with
93
+ GITPRO_DEBUG=1), exit 2."""
94
+ from . import incident
95
+ command = _command_path(args)
96
+ incident.record_crash(command, exc)
97
+ name = type(exc).__qualname__
98
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_.]{0,80}$", name):
99
+ name = "Exception"
100
+ if os.environ.get("GITPRO_DEBUG") == "1":
101
+ import traceback
102
+ traceback.print_exception(type(exc), exc, exc.__traceback__)
103
+ if getattr(args, "json", False):
104
+ from . import jsonout
105
+ jsonout.error(getattr(args, "command", None) or "gitpro", f"internal error ({name})")
106
+ else:
107
+ print(f"✗ internal error ({name}): this is a gitpro bug", file=sys.stderr)
108
+ print("→ draft a report with: gitpro report --crash", file=sys.stderr)
109
+ return 2
110
+
111
+
112
+
67
113
  def guarded(func: Callable[[argparse.Namespace], int], args: argparse.Namespace) -> int:
68
114
  """func(args) with gitpro's error handling: ConfigError, CalledProcessError, OSError and UnicodeError
69
115
  become exit 2 through fail(); Ctrl-C / EOF exit 130."""
@@ -80,3 +126,5 @@ def guarded(func: Callable[[argparse.Namespace], int], args: argparse.Namespace)
80
126
  except (KeyboardInterrupt, EOFError):
81
127
  print(file=sys.stderr)
82
128
  return 130
129
+ except Exception as e: # noqa: BLE001 - a bug in gitpro: record it, say so, exit 2
130
+ return _internal_error(args, e)
@@ -7,6 +7,7 @@ keeping every date; it never runs `rebase`, never touches pushed commits, the in
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ import argparse
10
11
  import os
11
12
  import re
12
13
  import shlex
@@ -18,7 +19,7 @@ from pathlib import Path
18
19
  from typing import Dict, List, Optional, Sequence, Set, Tuple
19
20
 
20
21
  from . import config, jsonout, resolve, trailers, undo
21
- from .args import capability
22
+ from .args import capability, dry_run, preview_only
22
23
  from .config import Config, ConfigError
23
24
 
24
25
  PROG = "gitpro"
@@ -31,7 +32,7 @@ FORMAT = "%H%x00%an%x00%ae%x00%cn%x00%ce%x00%cI%x00%P%x00%s%x00%(trailers:only,u
31
32
  # Options --range may contain besides revisions; anything else (--output=..., say) is refused.
32
33
  RANGE_OPTIONS = ("--not", "--all", "--remotes", "--branches", "--tags", "--first-parent", "--no-merges",
33
34
  "--merges", "--since", "--until", "--after", "--before", "--max-count")
34
- _CAUSES = ("url_form", "override", "pin_stale", "global_override", "not_applied", "env_override")
35
+ _CAUSES = ("ambiguous", "url_form", "override", "pin_stale", "global_override", "not_applied", "env_override")
35
36
 
36
37
 
37
38
  @dataclass
@@ -715,9 +716,11 @@ def _preview(data: dict, exp) -> List[str]:
715
716
 
716
717
  def cmd_audit(args) -> int:
717
718
  as_json = jsonout.wants_json(args)
719
+ args.rewrite = args.rewrite or getattr(args, "rewrite_flag", False)
718
720
  if args.rewrite and getattr(args, "agents", False):
719
721
  raise ConfigError("--rewrite needs the author audit; drop --agents")
720
- if args.rewrite and as_json and not args.yes:
722
+ show_only = bool(args.rewrite and args.dry_run and preview_only(args))
723
+ if args.rewrite and as_json and not args.yes and not show_only:
721
724
  raise ConfigError("--json needs -y with --rewrite (there is no terminal to confirm on)")
722
725
  home = Path(args.home).expanduser().resolve()
723
726
  path, how = config.find(args.config, home)
@@ -746,6 +749,12 @@ def cmd_audit(args) -> int:
746
749
  print()
747
750
  print("\n".join(_preview(data, exp)))
748
751
  n = data["rewrite"]["commits"]
752
+ if show_only:
753
+ if as_json:
754
+ jsonout.emit("audit", dict(_public(data), dry_run=True))
755
+ else:
756
+ print(f"Dry run: nothing written. Rewrite the {n} unpushed commit{'s' * (n != 1)} with -y.")
757
+ return 0 if data["ok"] else 1
749
758
  if not _confirm(f"rewrite {n} unpushed commit{'s' * (n != 1)}?", args.yes):
750
759
  print("✗ aborted, nothing written")
751
760
  return 1
@@ -773,7 +782,7 @@ def cmd_audit(args) -> int:
773
782
  def register(sub, common, confirm) -> None:
774
783
  sp = sub.add_parser(
775
784
  "audit", parents=[common, confirm],
776
- help="find commits made as the wrong identity or with an AI agent; fix unpushed ones with --rewrite",
785
+ help="find commits made as the wrong identity or with an AI agent; fix unpushed ones with --fix",
777
786
  description="Audit the history of a repo: commits whose committer (or your own other email as author) is "
778
787
  "not the repo's identity, commits made with an AI agent (Co-Authored-By trailers, bot "
779
788
  "emails) and the [*.trailers] policy. Default: the unpushed commits (as of the last fetch) "
@@ -792,9 +801,11 @@ def register(sub, common, confirm) -> None:
792
801
  w = sp.add_mutually_exclusive_group()
793
802
  w.add_argument("--agents", action="store_true", help="only the agent / trailer audit")
794
803
  w.add_argument("--authors", action="store_true", help="only the wrong-author audit")
795
- sp.add_argument("--rewrite", action="store_true",
804
+ sp.add_argument("--fix", dest="rewrite", action="store_true",
796
805
  help="fix the unpushed wrong-author commits (asks first; writes an undo script)")
806
+ sp.add_argument("--rewrite", dest="rewrite_flag", action="store_true", help=argparse.SUPPRESS) # 0.4: --fix
807
+ dry_run(sp)
797
808
  sp.add_argument("--json", action="store_true", help="machine-readable output")
798
809
  sp.add_argument("-v", "--verbose", action="store_true", help="list every commit, not the first ten")
799
810
  sp.set_defaults(func=cmd_audit)
800
- capability("audit", "audit.rewrite", "trailers")
811
+ capability("audit", "audit.rewrite", "audit.fix", "trailers")