gitpro 0.3.4__tar.gz → 0.4.1__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 (60) hide show
  1. {gitpro-0.3.4 → gitpro-0.4.1}/PKG-INFO +30 -24
  2. {gitpro-0.3.4 → gitpro-0.4.1}/README.md +29 -23
  3. {gitpro-0.3.4 → gitpro-0.4.1}/pyproject.toml +2 -1
  4. gitpro-0.4.1/src/gitpro/VERSION +4 -0
  5. gitpro-0.4.1/src/gitpro/__init__.py +10 -0
  6. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/agents.py +30 -32
  7. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/approval_policy.py +2 -1
  8. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/args.py +21 -0
  9. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/audit.py +18 -19
  10. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/change_contract.py +4 -5
  11. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/changes.py +8 -16
  12. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/cli.py +245 -50
  13. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/clone.py +3 -7
  14. gitpro-0.4.1/src/gitpro/commitcheck.py +118 -0
  15. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/completion.py +2 -2
  16. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/config.py +5 -7
  17. gitpro-0.4.1/src/gitpro/deprecation.py +36 -0
  18. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/devcontainer.py +1 -6
  19. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/discover.py +47 -26
  20. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/doctor.py +3 -6
  21. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/envcmd.py +1 -6
  22. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/files.py +11 -2
  23. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/forge.py +23 -29
  24. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/guard.py +9 -10
  25. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/health.py +6 -29
  26. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/hot.py +2 -1
  27. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/incident.py +3 -21
  28. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/jsonfile.py +78 -18
  29. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/jsonout.py +3 -0
  30. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/keys.py +3 -2
  31. gitpro-0.4.1/src/gitpro/learn.py +116 -0
  32. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/lost.py +7 -16
  33. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/mcp.py +99 -169
  34. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/operation_contract.py +16 -1
  35. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/operation_reconcile.py +2 -3
  36. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/operations.py +25 -12
  37. gitpro-0.4.1/src/gitpro/pin.py +166 -0
  38. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/plan.py +11 -6
  39. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/render.py +1 -4
  40. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/resolve.py +151 -25
  41. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/scan.py +117 -9
  42. gitpro-0.4.1/src/gitpro/setup.py +668 -0
  43. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/trailers.py +9 -5
  44. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/undo.py +3 -8
  45. gitpro-0.4.1/src/gitpro/util.py +34 -0
  46. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/view.py +21 -5
  47. gitpro-0.3.4/src/gitpro/__init__.py +0 -4
  48. gitpro-0.3.4/src/gitpro/pin.py +0 -89
  49. gitpro-0.3.4/src/gitpro/setup.py +0 -260
  50. {gitpro-0.3.4 → gitpro-0.4.1}/.gitignore +0 -0
  51. {gitpro-0.3.4 → gitpro-0.4.1}/LICENSE +0 -0
  52. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/__main__.py +0 -0
  53. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/example.toml +0 -0
  54. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/lint.py +0 -0
  55. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/operation_io.py +0 -0
  56. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/policy.py +0 -0
  57. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/prompt.py +0 -0
  58. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/report.py +0 -0
  59. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/scanroots.py +0 -0
  60. {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/tui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitpro
3
- Version: 0.3.4
3
+ Version: 0.4.1
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,9 +112,6 @@ 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.
108
-
109
115
  ## Problems and questions
110
116
 
111
117
  Run `gitpro report` to draft an issue with the versions and a redacted
@@ -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,9 +86,6 @@ 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.
82
-
83
89
  ## Problems and questions
84
90
 
85
91
  Run `gitpro report` to draft an issue with the versions and a redacted
@@ -36,7 +36,8 @@ Issues = "https://github.com/Yantrakar/gitpro/issues"
36
36
  gitpro = "gitpro.hot:main"
37
37
 
38
38
  [tool.hatch.version]
39
- path = "src/gitpro/__init__.py"
39
+ path = "src/gitpro/VERSION"
40
+ pattern = '\A(?P<version>\d+\.\d+\.\d+)'
40
41
 
41
42
  [tool.hatch.build.targets.wheel]
42
43
  packages = ["src/gitpro"]
@@ -0,0 +1,4 @@
1
+ 0.4.1 2026-10-18 secret masking, atomic writes, re-runnable release, version in one file
2
+ 0.4.0 2026-10-16 setup replaces init and apply; guard refuses mixed-identity repos
3
+ 0.3.4 report, support links and xdist CI
4
+ 0.3.3 gitpro icon, links to gitpro.dev and MCP Registry metadata
@@ -0,0 +1,10 @@
1
+ """Per-remote git identities generated from one TOML file (see cli.py)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+ # The release version is written only in the VERSION file next to this one (hatch reads it too):
7
+ # the first word of its first line. os and open() only: the hooks import this package on every
8
+ # commit, so nothing heavier is loaded.
9
+ with open(os.path.join(os.path.dirname(__file__), "VERSION"), encoding="utf-8") as _f:
10
+ __version__ = _f.readline().split()[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 "
@@ -1322,6 +1322,8 @@ def _judge_one(c: Call, rep, cfg, own: set, pushed_to: Optional[str] = None) ->
1322
1322
  from .resolve import redact_url
1323
1323
  url = redact_url(next(iter(rep.effective.remotes.values()), None)) or top
1324
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"
1325
1327
  elif c_bad or a_bad:
1326
1328
  email, verb = (committer, "committed") if c_bad else (author, "authored")
1327
1329
  email = email or "(no email)"
@@ -1654,20 +1656,10 @@ def _precheck(path: Path, t: Target, snippet: str) -> Optional[object]:
1654
1656
  """The file's JSON (None when missing); ConfigError for anything gitpro must not edit."""
1655
1657
  from . import jsonfile, undo
1656
1658
  from .config import ConfigError
1657
- shown = undo.tilde(path)
1658
- try:
1659
- text = path.read_text(encoding="utf-8")
1660
- except FileNotFoundError:
1659
+ obj = jsonfile.read_object(path, snippet)
1660
+ if obj is None:
1661
1661
  return None
1662
- try:
1663
- obj = json.loads(text)
1664
- except ValueError:
1665
- if jsonfile.strip_jsonc(text)[1]:
1666
- raise ConfigError(f"{shown} has comments; gitpro cannot edit it without losing them"
1667
- f" — add this by hand: {snippet}") from None
1668
- raise ConfigError(f"{shown} is not valid JSON; fix it by hand (nothing written)") from None
1669
- if not isinstance(obj, dict):
1670
- raise ConfigError(f"{shown} is not a JSON object; fix it by hand (nothing written)")
1662
+ shown = undo.tilde(path)
1671
1663
  hooks = obj.get("hooks", {})
1672
1664
  if not isinstance(hooks, dict):
1673
1665
  raise ConfigError(f"{shown}: hooks is not an object; fix it by hand (nothing written)")
@@ -1687,8 +1679,8 @@ def _repo_top() -> Optional[Path]:
1687
1679
 
1688
1680
 
1689
1681
  def _git_out(top: Path, *args: str, timeout: float = 30) -> subprocess.CompletedProcess:
1690
- env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
1691
- return subprocess.run(["git", "-C", str(top), *args], capture_output=True, text=True, env=env,
1682
+ from . import resolve
1683
+ return subprocess.run(["git", "-C", str(top), *args], capture_output=True, text=True, env=resolve._child_env(),
1692
1684
  stdin=subprocess.DEVNULL, timeout=timeout)
1693
1685
 
1694
1686
 
@@ -1722,20 +1714,6 @@ def _exclude_local(top: Path, script: Optional[Path]) -> bool:
1722
1714
  return True
1723
1715
 
1724
1716
 
1725
- def _confirm_with(args: argparse.Namespace, question: str) -> Callable[[str], bool]:
1726
- def confirm(diff: str) -> bool:
1727
- from .config import ConfigError
1728
- if args.yes:
1729
- return True
1730
- if not sys.stdin.isatty():
1731
- raise ConfigError("no terminal to confirm on; re-run with --yes")
1732
- from . import view
1733
- if diff:
1734
- print(diff, end="" if diff.endswith("\n") else "\n")
1735
- return view.confirm(question)
1736
- return confirm
1737
-
1738
-
1739
1717
  def _edit(args: argparse.Namespace, removing: bool) -> int:
1740
1718
  from . import args as gargs
1741
1719
  from . import jsonfile, jsonout, undo
@@ -1796,7 +1774,7 @@ def _edit(args: argparse.Namespace, removing: bool) -> int:
1796
1774
  "hook": _snippet(t), "backup": None, "undo": None})
1797
1775
  question = f"{'remove' if removing else 'install'} the {title} hook?"
1798
1776
  res = jsonfile.edit_json(t.path, change, command=command, create={},
1799
- confirm=_confirm_with(args, question), mkdir=True, snippet=_snippet(t))
1777
+ confirm=jsonfile.confirm_once(args, question), mkdir=True, snippet=_snippet(t))
1800
1778
  if not res.changed:
1801
1779
  from . import view
1802
1780
  view.aborted()
@@ -1812,11 +1790,31 @@ def _edit(args: argparse.Namespace, removing: bool) -> int:
1812
1790
  view.step("added .claude/settings.local.json to .git/info/exclude so it is not committed")
1813
1791
  view.ok(f"removed the gitpro hook from {shown}" if removing else
1814
1792
  f"installed; {title} picks it up on its next session")
1815
- restores = f"restores from {undo.tilde(res.backup)}" if res.backup else f"removes {shown}"
1793
+ restores = f"restores from {undo.tilde(res.backup)}" if res.backup else f"removes {shown} unless it changed since"
1816
1794
  print(f"↩ undo: sh {undo.tilde(res.undo)} ({restores})")
1817
1795
  return 0
1818
1796
 
1819
1797
 
1798
+ HOOK_AGENTS = ("claude", "cursor", "gemini")
1799
+
1800
+
1801
+ def plugin_enabled(home: Path) -> Optional[str]:
1802
+ """The enabled gitpro plugin in ~/.claude/settings.json (it ships the hook and the MCP server), or None."""
1803
+ t = target("claude", "user", home, None)
1804
+ return _plugin(_read_lenient(t.path)) if t.path.is_file() else None
1805
+
1806
+
1807
+ def hook_change(agent: str, home: Path) -> Optional[str]:
1808
+ """For gitpro setup: what `agent install --agent AGENT` would do at user scope ("create PATH" or
1809
+ "update PATH"), or None when the hook is in place. ConfigError for a file gitpro must not edit."""
1810
+ from . import undo
1811
+ t = target(agent, "user", home, gitpro_path())
1812
+ obj = _precheck(t.path, t, _snippet(t))
1813
+ if obj is not None and add_hook(copy.deepcopy(obj), t) == obj:
1814
+ return None
1815
+ return f"{'update' if obj is not None else 'create'} {undo.tilde(t.path)}"
1816
+
1817
+
1820
1818
  def install(args: argparse.Namespace) -> int:
1821
1819
  """`gitpro agent install`."""
1822
1820
  return _edit(args, removing=False)
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  import re
10
10
  from typing import Optional
11
11
 
12
+ from .config import SCAN_CONTROL_RE
12
13
  from .operation_contract import OperationError, decode, digest
13
14
 
14
15
  MAX_POLICY = 65536
@@ -43,7 +44,7 @@ def _invalid(message: str) -> OperationError:
43
44
 
44
45
 
45
46
  def _path(value: object) -> Path:
46
- if not isinstance(value, str) or not value or any(ord(c) < 32 or ord(c) == 127 for c in value):
47
+ if not isinstance(value, str) or not value or SCAN_CONTROL_RE.search(value):
47
48
  raise _invalid("Policy paths must be absolute, normalized paths without control characters.")
48
49
  path = Path(value)
49
50
  if not path.is_absolute() or ".." in path.parts or str(path) != value or path.resolve() != path:
@@ -43,7 +43,17 @@ def confirm() -> argparse.ArgumentParser:
43
43
  return p
44
44
 
45
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
+
46
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
47
57
  return not (getattr(args, "yes", False) or getattr(args, "write", False))
48
58
 
49
59
 
@@ -56,6 +66,17 @@ def preview_result(args: object, command: str, data: dict) -> int:
56
66
  return 0
57
67
 
58
68
 
69
+ def ask(question: str, yes: bool) -> bool:
70
+ """True for -y; else ask on the terminal. Without one, a ConfigError (the caller exits 2)."""
71
+ if yes:
72
+ return True
73
+ if not sys.stdin.isatty():
74
+ from .config import ConfigError
75
+ raise ConfigError("no terminal to confirm on; re-run with --yes")
76
+ from . import view
77
+ return view.confirm(question)
78
+
79
+
59
80
  def fail(msg: str, args: object) -> int:
60
81
  """Exit 2 with msg: the JSON error object on stdout under --json (nothing on stderr), else ✗ on stderr."""
61
82
  if getattr(args, "json", False):
@@ -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,8 +19,9 @@ 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 ask as _confirm, capability, dry_run, preview_only
22
23
  from .config import Config, ConfigError
24
+ from .util import sh_tilde as _sh
23
25
 
24
26
  PROG = "gitpro"
25
27
  DEFAULT_RANGE = "HEAD --not --remotes"
@@ -31,7 +33,7 @@ FORMAT = "%H%x00%an%x00%ae%x00%cn%x00%ce%x00%cI%x00%P%x00%s%x00%(trailers:only,u
31
33
  # Options --range may contain besides revisions; anything else (--output=..., say) is refused.
32
34
  RANGE_OPTIONS = ("--not", "--all", "--remotes", "--branches", "--tags", "--first-parent", "--no-merges",
33
35
  "--merges", "--since", "--until", "--after", "--before", "--max-count")
34
- _CAUSES = ("url_form", "override", "pin_stale", "global_override", "not_applied", "env_override")
36
+ _CAUSES = ("ambiguous", "url_form", "override", "pin_stale", "global_override", "not_applied", "env_override")
35
37
 
36
38
 
37
39
  @dataclass
@@ -590,10 +592,6 @@ def _rewrite_cmd(data: dict, args) -> str:
590
592
  return shown + " " + _sh(resolve._tilde(data["repo"]))
591
593
 
592
594
 
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
595
  def render(data: dict, args) -> List[str]:
598
596
  """The text report, one line per item."""
599
597
  verbose = bool(getattr(args, "verbose", False))
@@ -671,15 +669,6 @@ def _public(data: dict) -> dict:
671
669
  return out
672
670
 
673
671
 
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
672
  def _preview(data: dict, exp) -> List[str]:
684
673
  rw = data["rewrite"]
685
674
  branch = data["branch"]
@@ -715,9 +704,11 @@ def _preview(data: dict, exp) -> List[str]:
715
704
 
716
705
  def cmd_audit(args) -> int:
717
706
  as_json = jsonout.wants_json(args)
707
+ args.rewrite = args.rewrite or getattr(args, "rewrite_flag", False)
718
708
  if args.rewrite and getattr(args, "agents", False):
719
709
  raise ConfigError("--rewrite needs the author audit; drop --agents")
720
- if args.rewrite and as_json and not args.yes:
710
+ show_only = bool(args.rewrite and args.dry_run and preview_only(args))
711
+ if args.rewrite and as_json and not args.yes and not show_only:
721
712
  raise ConfigError("--json needs -y with --rewrite (there is no terminal to confirm on)")
722
713
  home = Path(args.home).expanduser().resolve()
723
714
  path, how = config.find(args.config, home)
@@ -746,6 +737,12 @@ def cmd_audit(args) -> int:
746
737
  print()
747
738
  print("\n".join(_preview(data, exp)))
748
739
  n = data["rewrite"]["commits"]
740
+ if show_only:
741
+ if as_json:
742
+ jsonout.emit("audit", dict(_public(data), dry_run=True))
743
+ else:
744
+ print(f"Dry run: nothing written. Rewrite the {n} unpushed commit{'s' * (n != 1)} with -y.")
745
+ return 0 if data["ok"] else 1
749
746
  if not _confirm(f"rewrite {n} unpushed commit{'s' * (n != 1)}?", args.yes):
750
747
  print("✗ aborted, nothing written")
751
748
  return 1
@@ -773,7 +770,7 @@ def cmd_audit(args) -> int:
773
770
  def register(sub, common, confirm) -> None:
774
771
  sp = sub.add_parser(
775
772
  "audit", parents=[common, confirm],
776
- help="find commits made as the wrong identity or with an AI agent; fix unpushed ones with --rewrite",
773
+ help="find commits made as the wrong identity or with an AI agent; fix unpushed ones with --fix",
777
774
  description="Audit the history of a repo: commits whose committer (or your own other email as author) is "
778
775
  "not the repo's identity, commits made with an AI agent (Co-Authored-By trailers, bot "
779
776
  "emails) and the [*.trailers] policy. Default: the unpushed commits (as of the last fetch) "
@@ -792,9 +789,11 @@ def register(sub, common, confirm) -> None:
792
789
  w = sp.add_mutually_exclusive_group()
793
790
  w.add_argument("--agents", action="store_true", help="only the agent / trailer audit")
794
791
  w.add_argument("--authors", action="store_true", help="only the wrong-author audit")
795
- sp.add_argument("--rewrite", action="store_true",
792
+ sp.add_argument("--fix", dest="rewrite", action="store_true",
796
793
  help="fix the unpushed wrong-author commits (asks first; writes an undo script)")
794
+ sp.add_argument("--rewrite", dest="rewrite_flag", action="store_true", help=argparse.SUPPRESS) # 0.4: --fix
795
+ dry_run(sp)
797
796
  sp.add_argument("--json", action="store_true", help="machine-readable output")
798
797
  sp.add_argument("-v", "--verbose", action="store_true", help="list every commit, not the first ten")
799
798
  sp.set_defaults(func=cmd_audit)
800
- capability("audit", "audit.rewrite", "trailers")
799
+ capability("audit", "audit.rewrite", "audit.fix", "trailers")
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
 
9
9
  from . import config
10
10
  from .approval_policy import AUTHORITY_SCHEMA
11
- from .operation_contract import MAX_REQUEST, OperationError
11
+ from .operation_contract import DECISIONS, MAX_REQUEST, OperationError
12
12
 
13
13
  KINDS = {
14
14
  "configure": ("config_path", "toml"),
@@ -40,8 +40,7 @@ RESULT_SCHEMA = {
40
40
  "properties": {
41
41
  "version": {"const": 1}, "command": {"enum": ["change propose", "change approve", "change apply",
42
42
  "change status", "change recover"]},
43
- "ok": {"type": "boolean"}, "decision": {"enum": ["ready", "blocked", "needs_input",
44
- "needs_approval", "indeterminate"]},
43
+ "ok": {"type": "boolean"}, "decision": {"enum": list(DECISIONS)},
45
44
  "problems": {"type": "array", "items": {
46
45
  "type": "object", "required": ["code", "message", "decision"]}},
47
46
  "plan_id": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
@@ -100,13 +99,13 @@ def parse(raw: object) -> dict:
100
99
  if not source.is_absolute() or source.suffix != ".toml" or home not in source.parents:
101
100
  raise OperationError("invalid_config_path", "Use an absolute .toml configuration path inside your home.",
102
101
  "needs_input")
103
- if ".." in source.parts or any(ord(c) < 32 or ord(c) == 127 for c in str(source)):
102
+ if ".." in source.parts or config.SCAN_CONTROL_RE.search(str(source)):
104
103
  raise OperationError("invalid_config_path", "Use a normalized configuration path without control characters.",
105
104
  "needs_input")
106
105
  out["config_path"] = str(source)
107
106
  if kind == "repo-identity":
108
107
  repo = Path(raw["repo"])
109
- if not repo.is_absolute() or any(ord(c) < 32 or ord(c) == 127 for c in str(repo)):
108
+ if not repo.is_absolute() or config.SCAN_CONTROL_RE.search(str(repo)):
110
109
  raise OperationError("invalid_repo", "Supply an absolute worktree root.", "needs_input")
111
110
  out["repo"] = str(repo.resolve())
112
111
  return out
@@ -19,7 +19,7 @@ from typing import Iterator, Optional
19
19
  from . import change_contract as contract
20
20
  from . import approval_policy
21
21
  from . import config, files, jsonout, resolve
22
- from .operation_contract import MAX_REQUEST, OperationError, decode, digest
22
+ from .operation_contract import MAX_REQUEST, OperationError, decode, digest, exit_code, read_text
23
23
  from .operation_io import Ledger, configuration_lock, process, repository_lock, state_dir, termination_signals
24
24
  from .operation_io import sync_path as _sync
25
25
 
@@ -199,7 +199,7 @@ class Store(Ledger):
199
199
  continue
200
200
  if (current.st_dev, current.st_ino, current.st_ctime_ns) == (device, inode, ctime):
201
201
  path.unlink()
202
- _sync_dir(path.parent)
202
+ _sync(path.parent, directory=True)
203
203
 
204
204
 
205
205
  class Builder:
@@ -510,10 +510,6 @@ def approve(proposal: object) -> dict:
510
510
  "be durably recorded. Inspect change status.", "indeterminate"))
511
511
 
512
512
 
513
- def _sync_dir(path: Path) -> None:
514
- _sync(path, directory=True)
515
-
516
-
517
513
  @contextlib.contextmanager
518
514
  def _target_lock(path: Path, store: Store, identifier: str) -> Iterator[None]:
519
515
  lock = path.with_name(path.name + ".lock")
@@ -534,7 +530,7 @@ def _target_lock(path: Path, store: Store, identifier: str) -> Iterator[None]:
534
530
  if current and (st.st_dev, st.st_ino, st.st_ctime_ns) == (
535
531
  current.st_dev, current.st_ino, current.st_ctime_ns):
536
532
  lock.unlink()
537
- _sync_dir(lock.parent)
533
+ _sync(lock.parent, directory=True)
538
534
 
539
535
 
540
536
  def _replace(path: Path, expected: Optional[dict], wanted: Optional[dict], store: Store, identifier: str) -> None:
@@ -542,7 +538,7 @@ def _replace(path: Path, expected: Optional[dict], wanted: Optional[dict], store
542
538
  missing = [p for p in path.parents if not p.exists()]
543
539
  for parent in reversed(missing):
544
540
  parent.mkdir(mode=0o700)
545
- _sync_dir(parent.parent)
541
+ _sync(parent.parent, directory=True)
546
542
  with _target_lock(path, store, identifier):
547
543
  if _snapshot(path) != expected:
548
544
  raise OperationError("stale_plan", f"A file changed before replacement: {path}", "blocked")
@@ -553,7 +549,7 @@ def _replace(path: Path, expected: Optional[dict], wanted: Optional[dict], store
553
549
  files.atomic_write(path, _bytes(wanted), wanted["mode"], follow_symlinks=False,
554
550
  overwrite=expected is not None)
555
551
  _sync(path)
556
- _sync_dir(path.parent)
552
+ _sync(path.parent, directory=True)
557
553
  if not _same_content(_snapshot(path), wanted):
558
554
  raise OperationError("verification_failed", f"Replacement could not be verified: {path}", "indeterminate")
559
555
 
@@ -571,7 +567,7 @@ def _rollback(document: dict, store: Store) -> list[str]:
571
567
  if _same_content(current, change["before"]):
572
568
  if current is not None:
573
569
  _sync(path)
574
- _sync_dir(path.parent)
570
+ _sync(path.parent, directory=True)
575
571
  continue
576
572
  if not _same_content(current, change["after"]):
577
573
  conflicts.append(str(path))
@@ -773,11 +769,7 @@ def command(args: argparse.Namespace) -> int:
773
769
  if not source:
774
770
  raise OperationError("missing_input", "Supply the request/proposal JSON file.", "needs_input")
775
771
  limit = MAX_REQUEST if action == "propose" else MAX_PLAN
776
- if source == "-":
777
- text = sys.stdin.read(limit + 1)
778
- else:
779
- with open(source, encoding="utf-8") as stream:
780
- text = stream.read(limit + 1)
772
+ text = read_text(source, limit)
781
773
  if action == "propose":
782
774
  result = propose(decode(text), args.config, policy_path=args.approval_policy)
783
775
  else:
@@ -824,7 +816,7 @@ def command(args: argparse.Namespace) -> int:
824
816
  result = contract.error(action, OperationError("invalid_request", "Could not read the JSON input.",
825
817
  "needs_input"))
826
818
  jsonout.emit(result["command"], result)
827
- return 0 if result["ok"] else (3 if result["decision"] == "blocked" else 2)
819
+ return exit_code(result)
828
820
 
829
821
 
830
822
  def register(sub: argparse._SubParsersAction, common: argparse.ArgumentParser,