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.
- {gitpro-0.3.4 → gitpro-0.4.1}/PKG-INFO +30 -24
- {gitpro-0.3.4 → gitpro-0.4.1}/README.md +29 -23
- {gitpro-0.3.4 → gitpro-0.4.1}/pyproject.toml +2 -1
- gitpro-0.4.1/src/gitpro/VERSION +4 -0
- gitpro-0.4.1/src/gitpro/__init__.py +10 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/agents.py +30 -32
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/approval_policy.py +2 -1
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/args.py +21 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/audit.py +18 -19
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/change_contract.py +4 -5
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/changes.py +8 -16
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/cli.py +245 -50
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/clone.py +3 -7
- gitpro-0.4.1/src/gitpro/commitcheck.py +118 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/completion.py +2 -2
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/config.py +5 -7
- gitpro-0.4.1/src/gitpro/deprecation.py +36 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/devcontainer.py +1 -6
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/discover.py +47 -26
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/doctor.py +3 -6
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/envcmd.py +1 -6
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/files.py +11 -2
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/forge.py +23 -29
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/guard.py +9 -10
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/health.py +6 -29
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/hot.py +2 -1
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/incident.py +3 -21
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/jsonfile.py +78 -18
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/jsonout.py +3 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/keys.py +3 -2
- gitpro-0.4.1/src/gitpro/learn.py +116 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/lost.py +7 -16
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/mcp.py +99 -169
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/operation_contract.py +16 -1
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/operation_reconcile.py +2 -3
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/operations.py +25 -12
- gitpro-0.4.1/src/gitpro/pin.py +166 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/plan.py +11 -6
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/render.py +1 -4
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/resolve.py +151 -25
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/scan.py +117 -9
- gitpro-0.4.1/src/gitpro/setup.py +668 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/trailers.py +9 -5
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/undo.py +3 -8
- gitpro-0.4.1/src/gitpro/util.py +34 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/view.py +21 -5
- gitpro-0.3.4/src/gitpro/__init__.py +0 -4
- gitpro-0.3.4/src/gitpro/pin.py +0 -89
- gitpro-0.3.4/src/gitpro/setup.py +0 -260
- {gitpro-0.3.4 → gitpro-0.4.1}/.gitignore +0 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/LICENSE +0 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/__main__.py +0 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/example.toml +0 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/lint.py +0 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/operation_io.py +0 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/policy.py +0 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/prompt.py +0 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/report.py +0 -0
- {gitpro-0.3.4 → gitpro-0.4.1}/src/gitpro/scanroots.py +0 -0
- {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
|
+
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
|
|
51
|
+
gitpro setup
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
74
|
+
Then:
|
|
64
75
|
|
|
65
76
|
```sh
|
|
66
|
-
gitpro why #
|
|
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?
|
|
70
|
-
|
|
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
|
|
94
|
+
and key uploads are never part of setup.
|
|
83
95
|
|
|
84
96
|
## Using VS Code?
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
25
|
+
gitpro setup
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
48
|
+
Then:
|
|
38
49
|
|
|
39
50
|
```sh
|
|
40
|
-
gitpro why #
|
|
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?
|
|
44
|
-
|
|
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
|
|
68
|
+
and key uploads are never part of setup.
|
|
57
69
|
|
|
58
70
|
## Using VS Code?
|
|
59
71
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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/
|
|
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
|
-
|
|
1658
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1691
|
-
return subprocess.run(["git", "-C", str(top), *args], capture_output=True, text=True, 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=
|
|
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
|
|
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
|
-
|
|
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 --
|
|
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":
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
819
|
+
return exit_code(result)
|
|
828
820
|
|
|
829
821
|
|
|
830
822
|
def register(sub: argparse._SubParsersAction, common: argparse.ArgumentParser,
|