gitpro 0.4.5__tar.gz → 0.4.6__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 (59) hide show
  1. {gitpro-0.4.5 → gitpro-0.4.6}/PKG-INFO +1 -1
  2. {gitpro-0.4.5 → gitpro-0.4.6}/pyproject.toml +4 -1
  3. gitpro-0.4.6/skills/gitpro/SKILL.md +359 -0
  4. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/VERSION +1 -0
  5. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/cli.py +2 -2
  6. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/commitcheck.py +17 -2
  7. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/files.py +4 -2
  8. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/guard.py +17 -0
  9. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/mcp.py +32 -7
  10. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/resolve.py +20 -9
  11. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/setup.py +110 -20
  12. gitpro-0.4.6/src/gitpro/skillcopy.py +65 -0
  13. {gitpro-0.4.5 → gitpro-0.4.6}/.gitignore +0 -0
  14. {gitpro-0.4.5 → gitpro-0.4.6}/LICENSE +0 -0
  15. {gitpro-0.4.5 → gitpro-0.4.6}/README.md +0 -0
  16. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/__init__.py +0 -0
  17. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/__main__.py +0 -0
  18. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/agents.py +0 -0
  19. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/approval_policy.py +0 -0
  20. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/args.py +0 -0
  21. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/audit.py +0 -0
  22. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/change_contract.py +0 -0
  23. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/changes.py +0 -0
  24. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/clone.py +0 -0
  25. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/completion.py +0 -0
  26. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/config.py +0 -0
  27. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/deprecation.py +0 -0
  28. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/devcontainer.py +0 -0
  29. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/discover.py +0 -0
  30. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/doctor.py +0 -0
  31. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/envcmd.py +0 -0
  32. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/example.toml +0 -0
  33. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/forge.py +0 -0
  34. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/health.py +0 -0
  35. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/hot.py +0 -0
  36. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/incident.py +0 -0
  37. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/jsonfile.py +0 -0
  38. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/jsonout.py +0 -0
  39. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/keys.py +0 -0
  40. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/learn.py +0 -0
  41. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/lint.py +0 -0
  42. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/lost.py +0 -0
  43. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/operation_contract.py +0 -0
  44. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/operation_io.py +0 -0
  45. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/operation_reconcile.py +0 -0
  46. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/operations.py +0 -0
  47. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/pin.py +0 -0
  48. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/plan.py +0 -0
  49. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/policy.py +0 -0
  50. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/prompt.py +0 -0
  51. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/render.py +0 -0
  52. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/report.py +0 -0
  53. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/scan.py +0 -0
  54. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/scanroots.py +0 -0
  55. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/trailers.py +0 -0
  56. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/tui.py +0 -0
  57. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/undo.py +0 -0
  58. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/util.py +0 -0
  59. {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitpro
3
- Version: 0.4.5
3
+ Version: 0.4.6
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
@@ -42,8 +42,11 @@ pattern = '\A(?P<version>\d+\.\d+\.\d+)'
42
42
  [tool.hatch.build.targets.wheel]
43
43
  packages = ["src/gitpro"]
44
44
 
45
+ [tool.hatch.build.targets.wheel.force-include]
46
+ "skills/gitpro/SKILL.md" = "gitpro/skill/SKILL.md" # the one source of the skill; setup -n compares copies to it
47
+
45
48
  [tool.hatch.build.targets.sdist]
46
- include = ["/src/gitpro", "/README.md", "/LICENSE"]
49
+ include = ["/src/gitpro", "/skills/gitpro/SKILL.md", "/README.md", "/LICENSE"]
47
50
 
48
51
  [tool.pytest.ini_options]
49
52
  testpaths = ["tests"]
@@ -0,0 +1,359 @@
1
+ ---
2
+ name: gitpro
3
+ description: Set up and manage which git name, email, SSH key and commit signing each repo uses, chosen by the repo's remote URL, with the gitpro CLI. Use when the user wants separate work/personal git identities, a second GitHub or GitLab account, commit signing per account, asks why a commit has the wrong email, commits from a dev container, or before committing, pushing or opening a pull request for the user.
4
+ license: MIT
5
+ ---
6
+
7
+ # gitpro
8
+
9
+ `gitpro` turns one TOML file into plain git and ssh config. Git then
10
+ picks the identity for each repo from its remote URL. Nothing of the tool runs
11
+ while git runs, unless the user installed the optional guard hooks.
12
+
13
+ Docs: https://github.com/Yantrakar/gitpro/blob/main/docs/guide.md
14
+
15
+ ## Rules
16
+
17
+ - **Never run `setup`, `uninstall`, `scan --fix`, `pin`, `clone` or `audit --fix`
18
+ without the user's explicit yes in this conversation.** Run `gitpro setup -n`
19
+ first, show the user what it says is still to do, and only after they
20
+ confirm run `gitpro setup -y`. Without a terminal, `setup` needs `-y` or `-n`
21
+ when there is something to change or to ask.
22
+ - Edit only the TOML config. The files it generates (`~/.gitconfig-identities`,
23
+ `~/.gitconfig-<id>`, `~/.ssh/config.d/git-identities`, the marked blocks in
24
+ `~/.gitconfig` and `~/.ssh/config`) are overwritten on every `setup`.
25
+ - Never read, print or copy private SSH keys. Public keys (`.pub`) are fine.
26
+ - `gitpro setup` sets up the whole machine (config, SSH keys, Git and SSH
27
+ files, the guard, the hook and MCP server of the agents it covers, and the
28
+ gitpro skill copies that already exist) after one yes. It
29
+ offers to look through the user's repos and proposes identities from them
30
+ (it never reads private keys, shell history or forge accounts). The user runs
31
+ it in their terminal: the first run asks questions, and new keys ask for a
32
+ passphrase. Don't run it yourself without their explicit yes.
33
+ - Don't invent owners, hosts or emails. Ask the user, or read them from the
34
+ remote URLs of their repos (`git remote -v`), or let `gitpro setup` find
35
+ them.
36
+ - Before any `git commit`, `git push` or `gh pr create` for the user, follow
37
+ "Before you commit, push or open a pull request" below.
38
+ - Never run `setup`, `guard install --repo`, `audit --fix`, `keys --upload` or
39
+ `clone` without an explicit yes. Always show the dry run first: `setup -n`,
40
+ `guard status`, `audit`, `clone --dry-run`. These commands ask on a
41
+ terminal and you have none, so pass `-y` only after the user said yes.
42
+ - `devcontainer.json`, `.mcp.json` and `.claude/settings.json` are team
43
+ files. gitpro prints what to add; edit them only when the user owns the
44
+ repo and asks for it.
45
+ - Never pass `--online`, `--history` or `--send-repo-names` unless the user
46
+ asks. Before an online command, tell the user what its first `online:` line
47
+ (or its "needs the network" message) says will be sent.
48
+ - When gitpro crashes (`✗ internal error (…): this is a gitpro bug`) or blocks
49
+ a commit or push the user thinks it should not, offer `gitpro report`
50
+ (`--crash`, `--denial`; `--security` for a vulnerability). Show the user
51
+ `gitpro report --print` and never open or file it without their yes in
52
+ this conversation.
53
+ - `gitpro env` prints the commands that fetch tokens, never the tokens. Do
54
+ not try to obtain token values, and never read `gh`/`glab` config files,
55
+ `.netrc` or `.git-credentials`.
56
+ - Use `--json` when you need to read a result; plain output when you show it
57
+ to the user. With `--json`, a writing command also needs `-y`; without it,
58
+ `scan --fix --json` only previews, like `-n`.
59
+ - A `***` in a URL gitpro prints is a password, token or secret query value
60
+ that it masked on purpose. The masking is for display only: do not try to
61
+ recover the value or paste the masked URL into a git command.
62
+ - Commands and MCP tools marked deprecated still work in 0.4.x and are removed
63
+ in 0.5.0. Use their replacements (see the end of the Commands section).
64
+ `GITPRO_NO_DEPRECATION=1` silences the deprecation line.
65
+
66
+ ## Before you commit, push or open a pull request
67
+
68
+ 1. When you may need to commit, push or open a PR, run `gitpro --version`
69
+ early, before the work is done.
70
+ 2. If gitpro is missing, ask the user before installing it (`pipx install
71
+ gitpro`). If nobody can answer, record "blocked: gitpro missing" as a
72
+ blocker, keep doing the work that needs no commit, and do not write a done
73
+ or finish marker while a commit is blocked.
74
+ 3. Before each commit, run `gitpro why --json` in the repo (or call the `why`
75
+ MCP tool) with the real `--author`, `--committer` and `--message` you will
76
+ use. Read the repo's `problems` and `commit.problems`. Exit code 0 alone is
77
+ not a pass: `why` exits 0 for a wrong committer. An error or a missing
78
+ field is not a pass.
79
+ 4. `commit.would_block` is the agent hook's verdict (`[agents].hook`).
80
+ `commit.guard_would_block` is the Git guard's (`[guard].on_mismatch`). Stop
81
+ on a problem or on either being true, and show the user the `fix`.
82
+ 5. A repo with no remote still uses gitpro's `[default]` identity.
83
+ 6. Commit without gitpro only if all of these hold: the operator said so for
84
+ that repo, the repo has no remote, git's author and committer are the
85
+ identity the operator named, and you log it in the run log. Do not use an
86
+ environment variable for it.
87
+ 7. Push and PR always fail closed. `why` checks the identity, not the push
88
+ target, the account or the outgoing commits. If gitpro is missing or
89
+ `why` fails, do not push or open the PR.
90
+ 8. Never use `--no-verify`, `GITPRO_SKIP`, or turn off signing, unless the
91
+ user says so in this conversation.
92
+
93
+ Needs Linux or macOS, git 2.36+, Python 3.9+, OpenSSH. gitpro is on PyPI.
94
+ `gitpro --version --json` lists `capabilities`; check for one there (for
95
+ example `why` or `mcp`) instead of comparing version numbers.
96
+
97
+ ## The config
98
+
99
+ Path: `-c PATH`, else `$GITPRO_CONFIG`, else
100
+ `~/.config/gitpro.toml`. `gitpro setup` finds the user's identities in their repos (or asks), writes
101
+ the config and can add identities while preserving an existing config. The
102
+ example config uses placeholder emails and hosts (`*.example`, `example.com`)
103
+ that gitpro refuses until they are replaced.
104
+
105
+ ```toml
106
+ [default] # repos no identity matches (all keys optional)
107
+ name = "Jane Doe"
108
+ email = "jane@personal.dev"
109
+ ssh_key = "~/.ssh/id_ed25519"
110
+ ssh_hosts = ["github.com"] # ssh always uses ssh_key for these hosts
111
+ sign = true # SSH-sign commits and tags
112
+
113
+ [[identity]]
114
+ id = "work-github" # short name; settings go to ~/.gitconfig-<id>
115
+ name = "Jane Doe"
116
+ email = "jane.doe@employer.com"
117
+ host = "github.com"
118
+ owners = ["janedoe-employer", "employer-org"] # exact case, as in the remote URL
119
+ ssh_key = "~/.ssh/id_ed25519_work"
120
+ ssh_alias = "github-work" # ssh Host that offers only ssh_key
121
+ ```
122
+
123
+ - `id`, `name`, `email`, `host` are required for an identity.
124
+ - `owners`: GitHub users/orgs or GitLab groups/subgroups from the URL path,
125
+ case-sensitive. Omit to match every repo on `host`.
126
+ - A repo matches when any remote matches. Git applies the includes in file
127
+ order, so for one remote the later identity wins. When two remotes match
128
+ different identities, `why` and `scan` report the repo as ambiguous until
129
+ the user pins one (`gitpro pin --remote R`).
130
+ - Two accounts on the same host need separate `ssh_key`s. Give the extra one an
131
+ `ssh_alias`, otherwise ssh can fall back to the default key and sign in as
132
+ the default account.
133
+ - Other identity keys: `rewrite` (default true), `rewrite_https`, `ssh_user`,
134
+ `ssh_port`, `sign`, `file`, `gh_account`, `glab_account`, `clone_dir`,
135
+ `policy`, a `trailers` table (`co_authored_by`, `forbid_emails`), and extra
136
+ git settings under `[default.git.<section>]` / `[identity.git.<section>]`.
137
+ - Optional tables: `[scan]` (`roots`, `exclude`, `stale_days`), `[guard]`
138
+ (`hooks`, `on_mismatch`, `chain_to`), `[clone]` (`dir`, `layout`),
139
+ `[forge]` (`online`, `similar`), `[agents]` (`hook`, `bot_emails`),
140
+ `[policy]` (`files`). The user guide's configuration section explains each.
141
+ - Unknown keys are errors. Read the error message and fix the TOML.
142
+
143
+ ## Workflow for a change
144
+
145
+ 1. Read the config. Make the smallest edit that does what the user asked, with
146
+ their permission to edit configuration. Or let `gitpro setup --add PATH`
147
+ (run by the user) learn identities from their repos.
148
+ 2. `gitpro setup -n`. Show what it says is still to do and any `⚠` warning lines.
149
+ 3. On the user's yes: `gitpro setup -y`.
150
+ 4. If keys were added: `gitpro keys` prints each public key and the
151
+ account to register it with (twice on GitHub with `sign`: as an
152
+ authentication key and as a signing key). Test with `ssh -T git@<alias>`.
153
+ 5. Verify in a repo: `gitpro why <dir>`.
154
+
155
+ ## Other workflows
156
+
157
+ - **"The repo commits as the wrong person"**: `gitpro why` → show its `→`
158
+ fix and run it on the user's yes → `gitpro why` again until it says
159
+ `✓ identity matches`. If the repo has two remotes for two identities, `why`
160
+ and `scan` say so; the user chooses with `gitpro pin --remote R`. If history
161
+ is already wrong: `gitpro audit` → show the list → on the user's yes,
162
+ `gitpro audit --fix -y` (it rewrites only unpushed commits, needs a clean
163
+ working tree and prints an `↩ undo:` line; tell the user to fetch first,
164
+ since "pushed" means as of the last fetch).
165
+ - **"Set up a new machine"**: the user runs `pipx install gitpro` and
166
+ `gitpro setup` in their terminal (it offers to find their identities in
167
+ their repos) → `gitpro scan` (repos that need a fix) → `gitpro auth` (online:
168
+ say so first) if they want the forge checks.
169
+ - **"Lost work before wiping or moving a machine"**: `gitpro scan --lost`
170
+ (offline, read-only). Suggest a `git fetch` first so "unpushed" is current.
171
+ - **"Make the agent safe"**: `gitpro setup -n` shows which hooks and MCP
172
+ servers are in place, per agent (`agents` in `--json`). gitpro adds a hook
173
+ for Claude Code, Cursor and Gemini CLI, and an MCP server for those plus
174
+ Claude Desktop, GitHub Copilot CLI and opencode. For Codex it has
175
+ neither: `setup --agent codex` says so and `setup -n` exits 1. The Git
176
+ guard still checks its commits. Copilot CLI gets the MCP server only. "Installed"
177
+ means gitpro's entry is in the agent's config file, not that the agent
178
+ loaded it. Recommend the gitpro plugin (it brings the hook and the MCP
179
+ server) or `gitpro setup --agent claude`. The hook never grants anything; it
180
+ only denies with a reason.
181
+ - **"The gitpro skill is out of date"**: `gitpro setup -n` lists installed
182
+ copies of this skill (`~/.claude/skills/gitpro`, `~/.copilot/skills/gitpro`,
183
+ `~/.agents/skills/gitpro`; links to one file count once) that differ from
184
+ the one in this gitpro. `gitpro setup -y`, after the user's yes, updates
185
+ those copies with a backup. It never creates a copy.
186
+ - **"Clone a repo"**: `gitpro clone --dry-run <url>` → show the plan → on
187
+ the user's yes, `gitpro clone <url> -y`.
188
+ - **"Our team has rules"**: `gitpro team`, then the `gitpro check --policy
189
+ FILE` command it ends with (or `gitpro check --repo <dir>` for one repo)
190
+ for the details, and `gitpro policy show` for the rules. `gitpro why`
191
+ also shows a repo's policy problems. Plain `gitpro check` does not look at
192
+ policies.
193
+ - **"Set up the policy check in CI"**: GitHub, the Action in the user guide (`uses:
194
+ Yantrakar/gitpro@<ref>` with `fetch-depth: 0`); GitLab, the guide's
195
+ job. In CI, gitpro reads a policy file in the repo, and its
196
+ `allowed_signers`, as they are at the range base, so a pull request cannot
197
+ loosen the policy that judges it. A policy change in the PR takes effect
198
+ only once it is merged. Keep the range base on a protected branch (the
199
+ Action's default is), and recommend CODEOWNERS on `.gitpro-policy.toml`
200
+ and `allowed_signers`. These are team files: show the snippet and let the
201
+ user add it.
202
+
203
+ ## What `why` shows
204
+
205
+ `gitpro why` prints the remotes, the identity the repo should use and why, the
206
+ effective name and email, the SSH key and signing, and a `→` fix for most
207
+ problems (a few, such as a policy that cannot be loaded, have a diagnostic and
208
+ no fix). The text shows one origin per setting; `why --json` has a `settings`
209
+ object with the `value`, `origin` and `scope` of each. `--committer EMAIL`,
210
+ `--author EMAIL` and `--message TEXT` with `--json` fill the `commit` object:
211
+ `problems`, `would_block` and `guard_would_block`. `why` exits 1 when the repo
212
+ itself has a problem, and 2 on an error (with `--json`, an object with `error`).
213
+
214
+ ## Commands
215
+
216
+ Every command has `--help`. `gitpro --help` lists `setup`, `why`, `scan`, `pin`,
217
+ `clone`, `audit`, `env` and `uninstall`, then a "more" group.
218
+
219
+ The same flags mean the same everywhere: `-y` does not ask,
220
+ `--json` prints one JSON object, `-v` adds detail, a `PATH` names the repo or
221
+ folder, `-c PATH` the config. `setup -n` shows only; `setup` asks once on a terminal. `pin`,
222
+ `scan --fix` and `audit --fix` show a preview without `-y` (with `--json`,
223
+ `scan --fix` previews unless `-y` is there). A preview never grants approval.
224
+
225
+ | command | writes? | needs yes? | network |
226
+ |---|---|---|---|
227
+ | `why`, `prompt`, `completion`, `scan`, `scan --roots`, `scan --lost`, `audit`, `keys`, `keys --health`, `guard status`, `guard test`, `policy show`, `policy lint`, `team`, `env`, `setup -n`, `clone --dry-run` | no | no | no (`--online` is opt-in where present: ask first) |
228
+ | `report` | no | **yes**, to open the form or send; `-y` answers it | with `--online` (`gh`): say so |
229
+ | `guard check`, `agent-check`, `mcp` | no; git hooks, the agent hook and the MCP client call these | do not run them yourself | no |
230
+ | `auth`, `forge repos`, `forge forks`, `forge email` | no | no, but say it goes online | **yes**, say so |
231
+ | `setup` | yes: config, keys, Git and SSH files, guard, agents; previews first | **yes**, user runs it in their terminal; `-y` only after their yes | no |
232
+ | `scan --fix`, `pin`, `pin --remove`, `uninstall`, `guard install --repo`, `guard uninstall`, `agent uninstall`, `mcp uninstall`, `audit --fix` | yes | **yes**, then `-y` | no |
233
+ | `clone` | yes: a new directory | **yes**, then `-y` | yes: `git clone` |
234
+ | `keys --upload`, `forge similar` | adds keys to the account / sends repo names | **yes** + `--online` (+ `--send-repo-names`) | **yes** |
235
+
236
+ Deprecated in 0.4.0, removed in 0.5.0. They behave as in 0.3.4, print a line on
237
+ stderr and are hidden from `--help`:
238
+
239
+ | command | writes? | needs yes? | use instead |
240
+ |---|---|---|---|
241
+ | `init` | yes (deprecated): creates the example config | **yes**, then `-y` | `gitpro setup` |
242
+ | `apply` | yes (deprecated) | **yes**, then `-y` | `gitpro setup -y` |
243
+ | `unpin` | yes (deprecated) | **yes**, then `-y` | `gitpro pin --remove` |
244
+ | `audit --rewrite` | yes (deprecated) | **yes**, then `-y` | `gitpro audit --fix` |
245
+ | `guard install` | yes (deprecated without `--repo`) | **yes**, then `-y` | `gitpro setup` |
246
+ | `agent install`, `mcp install` | yes (deprecated) | **yes**, then `-y` | `gitpro setup --agent NAME` |
247
+ | `setup --write` | yes (deprecated) | **yes**, then `-y` | `gitpro setup -y` |
248
+ | `scan --suggest` | no (deprecated) | no | `gitpro setup --add PATH` (the user runs it) |
249
+ | `devcontainer --write` | yes (deprecated): **team file** | **yes**, then `-y` | VS Code "Set up devcontainer pin" |
250
+ | `keys --generate` | yes (deprecated): creates keys, interactive | do not run: ask the user to | `gitpro setup` |
251
+ | `plan`, plain `check`, `doctor`, `which`, `lost`, `discover`, `agent status`, `devcontainer`, `devcontainer status` | no (deprecated) | no | `setup -n`, `setup -n`, `setup -n` and `why`, `why --json`, `scan --lost`, `setup PATH`, `setup -n`, VS Code, VS Code |
252
+ | `preflight`, `operation-status`, `operation-reconcile`, `change propose`, `change status` | no (deprecated) | no | none |
253
+ | `run` | yes (deprecated) | **yes**, then `-y` | none |
254
+ | `change approve`, `change apply`, `change recover` | yes (deprecated) | **yes** | none |
255
+
256
+ Notes:
257
+
258
+ - Plain `check` only exits 1 when `apply` would change something; it never
259
+ looks at policies. `check --repo DIR` checks the repo's identity against its
260
+ team policy, `check --policy FILE` checks every identity that uses FILE,
261
+ and `check --policy FILE --range A..B` checks commits (CI; the base's policy
262
+ judges). Only plain `check` is deprecated (use `setup -n`); the policy forms
263
+ stay and are listed under "more".
264
+ - `setup -n` exits 0 when nothing is to do, 1 when something is (or when an
265
+ agent named with `--agent` cannot be covered), 2 on an error. `--json` has
266
+ `identities`, `pending`, `done`, `notes`, `agents` (per agent: `hook` and
267
+ `mcp`, each `installed`, `plugin`, `missing` or `unavailable`), `uncovered`
268
+ and `skills`; `ok` is false when `pending` or `uncovered` is not empty.
269
+ - `team --online` shows `⚠` (never `✗`) when the forge did not answer; the
270
+ reason is on stderr and in `warnings`.
271
+ - `scan --json`, `why --json` and the other `--json` forms print one JSON
272
+ object for you to read. `scan --lost` adds a `lost` object. `--lost` with
273
+ `--fix` is refused.
274
+ - `scan --fix` keeps an override that is a repo's only identity. Without `-y`
275
+ it shows a preview (`scan --fix --json` too). With `-y` it prints the undo
276
+ script's path.
277
+ - `pin --remote R` pins the identity that remote R's URL matches, for a repo
278
+ whose remotes match two identities. It also writes that identity's signing
279
+ and key settings. `pin --remove` with `--remote` is refused.
280
+ - `uninstall` removes the Git and SSH files `setup` wrote and the global
281
+ guard hooks. It keeps the config and the keys. It does not remove agent
282
+ hooks, MCP registrations, per-repo hooks, pins, skill copies or
283
+ `devcontainer.json` entries; each of those has its own undo.
284
+ - `env` output is meant for `eval "$(gitpro env)"` in the user's shell.
285
+ - A deprecated command with `--json` has `"deprecated": {"removed_in": "0.5.0", "use": …}`
286
+ in its object and prints nothing on stderr.
287
+
288
+ ## MCP server
289
+
290
+ The gitpro plugin (or `gitpro setup --agent NAME`) gives you an MCP server.
291
+ Its tools have the commands' names and return the same JSON as the matching
292
+ `--json` command:
293
+
294
+ - `why`: the repo's identity. Takes `path`, `remote`, `author_email`,
295
+ `committer_email` and `message`. Its `settings` field is the effective
296
+ settings; its `commit` field has `problems`, `would_block` (the agent hook's
297
+ verdict) and `guard_would_block` (the Git guard's). On an error it returns
298
+ `isError` with the same object `why --json` prints (`error`, `config: null`,
299
+ and the repo fields when the path is a repo).
300
+ - `setup`: the state of the machine (`setup -n --json`): identities and what is
301
+ still to do. Changes nothing.
302
+ - `scan`: the repos and what needs a fix. With `lost: true` it adds the lost-work
303
+ checks.
304
+ - `audit`: commits made as the wrong identity.
305
+ - `env`: the commands that fetch the forge token, never a token.
306
+ - `report`: a redacted bug report draft.
307
+
308
+ Deprecated tools stay in the list in 0.4.x, with a description that starts
309
+ `Deprecated, removed in 0.5.0; use <tool>.`, and are removed in 0.5.0
310
+ (`apply_change`, `recover_change` and `run_operation` are listed only when the
311
+ server runs with `--allow-execution`, as before):
312
+
313
+ | deprecated tool | use |
314
+ |---|---|
315
+ | `which_identity` | `why` (its `settings`) |
316
+ | `check_commit` | `why` with `committer_email`, `author_email`, `message` (its `commit`) |
317
+ | `identities` | `setup` |
318
+ | `lost` | `scan` with `lost: true` |
319
+ | `env_for` | `env` |
320
+ | `draft_report` | `report` |
321
+ | `preflight`, `operation_status`, `reconcile_operation`, `propose_change`, `change_status`, `apply_change`, `recover_change`, `run_operation` | none |
322
+
323
+ - Before committing, follow "Before you commit, push or open a pull request".
324
+ Pass `author_email`, `committer_email` and `message`.
325
+ - Every default tool is read-only. Tools listed only with `--allow-execution`
326
+ are not.
327
+ - A `fix` field is a command to propose to the user, not to run.
328
+
329
+ ## Troubleshooting
330
+
331
+ - Not sure what is wrong: `gitpro setup -n` shows what is missing on the
332
+ machine, and `gitpro why` explains one repo.
333
+ - Wrong or no identity: compare `why` output's remote URL with `host` and
334
+ `owners` (case!). A repo-level `user.email` overrides everything:
335
+ `gitpro scan <dir>` shows it, `gitpro scan --fix -y <dir>` removes it.
336
+ - "Please tell me who you are": no identity matches and `[default]` has no
337
+ name/email. This is intended; add an identity or a default. A repo with no
338
+ remote uses `[default]`; `why` says "gitpro has no identity for this repo"
339
+ and whether git will use another email or refuse.
340
+ - Inside a dev container (VS Code Dev Containers copy `~/.gitconfig` but not
341
+ its includes): on the host, run `gitpro pin` in the repo, or use VS Code's
342
+ "Set up devcontainer pin" so it happens on every start. An ordinary pin
343
+ does not pin signing or the SSH key; `pin --remote` writes both.
344
+ - Push or clone signs in as the wrong account: the identity's key isn't
345
+ registered with that account, or it has no `ssh_alias` while the host is in
346
+ `[default].ssh_hosts`. `gitpro auth` (online) shows which account each
347
+ alias signs in as.
348
+ - husky, lefthook or pre-commit: repos with their own `core.hooksPath` are
349
+ not covered by the global guard; suggest `gitpro guard install --repo <dir>`.
350
+ - "A hook blocked me": read the `cause:` and `fix:` lines, show them to the
351
+ user and ask before running the fix.
352
+ - "A hook printed `exited N; not blocking`": the guard failed open and the
353
+ commit went through. Run `gitpro why` in the repo and `gitpro setup -n`.
354
+ - The agent hook does nothing: it runs `gitpro` from a plain `sh`, so gitpro
355
+ must be on that `PATH` (`command -v gitpro`).
356
+ - A new agent shows `unavailable` in `setup -n`: gitpro has no hook or MCP
357
+ entry for it. That is not a gitpro failure; the Git guard still applies.
358
+ - Something else, or a gitpro bug: `gitpro report --print` drafts a redacted
359
+ issue (versions and `why`); the user decides whether to open it.
@@ -1,3 +1,4 @@
1
+ 0.4.6 2026-11-06 setup covers agents and skills, why gives the guard verdict
1
2
  0.4.5 2026-10-22 release: releasepro 0.2.2, plain short announcement
2
3
  0.4.4 2026-10-22 release: run by releasepro, notes from the changelog
3
4
  0.4.3 2026-10-21 release: gitpro.dev last, MCP Registry after PyPI only
@@ -494,8 +494,8 @@ def cmd_scan(args) -> int:
494
494
  if args.lost and args.fix:
495
495
  raise config.ConfigError("--lost lists work only you can sort out, so it does not go with --fix; "
496
496
  "run gitpro scan --lost without --fix")
497
- # -n shows the fix and changes nothing; --json without -y still writes (00-existing, known issue 2)
498
- preview = bool(args.fix and args.dry_run and preview_only(args))
497
+ # -n shows the fix and changes nothing; so does --json without -y (spec 23 section 3.1)
498
+ preview = bool(args.fix and ((args.dry_run and preview_only(args)) or (args.json and not args.yes)))
499
499
  if preview:
500
500
  args.fix = False
501
501
  ask = args.fix and not args.yes and not args.json
@@ -100,9 +100,23 @@ def call_spec(fn: Callable, values: List[Tuple[str, object]]):
100
100
  return fn(*rest[:len(positional)], **kwargs)
101
101
 
102
102
 
103
+ def guard_would_block(rep, cfg, committer_email: Optional[str], author_email: Optional[str],
104
+ probs: List[dict]) -> bool:
105
+ """Would the Git guard refuse this commit? The identity check is guard.py's own (guard.commit_verdict).
106
+ A trailer problem counts because the commit-msg hook refuses it. Both follow [guard] on_mismatch, not
107
+ [agents].hook. Team-policy hooks are not evaluated."""
108
+ from . import guard
109
+ committer = committer_email or rep.effective.email.value or None
110
+ verdict = guard.commit_verdict(rep, cfg, committer, author_email or committer)
111
+ trailer = any(p["code"] == "trailer" for p in probs) and cfg.guard.on_mismatch == "block"
112
+ return bool(verdict["would_block"]) or trailer
113
+
114
+
103
115
  def judge(rep, cfg, *, committer_email: Optional[str] = None, author_email: Optional[str] = None,
104
116
  message: Optional[str] = None) -> dict:
105
- """{would_block, mode, problems, reason, fix[, warnings]} for a commit in rep.repo."""
117
+ """{would_block, mode, problems, reason, fix, guard_would_block[, warnings]} for a commit in rep.repo.
118
+ would_block is the agent hook's verdict ([agents].hook); guard_would_block is the Git guard's
119
+ ([guard] on_mismatch)."""
106
120
  from . import resolve
107
121
  probs = identity_problems(rep, cfg, committer_email, author_email)
108
122
  probs += [_problem(p.code, p.message, resolve._shown_fix(p) or None) for p in rep.problems
@@ -112,7 +126,8 @@ def judge(rep, cfg, *, committer_email: Optional[str] = None, author_email: Opti
112
126
  probs += trailer_problems(rep.repo, rep, cfg, message, warnings)
113
127
  mode = cfg.agents.hook
114
128
  out = {"would_block": bool(probs) and mode == "block", "mode": mode, "problems": probs,
115
- "reason": probs[0]["message"] if probs else None, "fix": probs[0]["fix"] if probs else None}
129
+ "reason": probs[0]["message"] if probs else None, "fix": probs[0]["fix"] if probs else None,
130
+ "guard_would_block": guard_would_block(rep, cfg, committer_email, author_email, probs)}
116
131
  if warnings:
117
132
  out["warnings"] = warnings
118
133
  return out
@@ -72,12 +72,14 @@ def show(changes: list[Change], diffs: bool) -> None:
72
72
  (c.old or "").splitlines(True), (c.new or "").splitlines(True), f"a{c.path}", f"b{c.path}"))
73
73
 
74
74
 
75
- def _backup(path: Path, data: bytes | None = None) -> Path:
75
+ def _backup(path: Path, data: bytes | None = None, into: Path | None = None) -> Path:
76
+ """A copy of path (O_EXCL, 0600) named <name>.bak.<stamp>, beside it or in the folder into."""
76
77
  stamp = time.strftime("%Y%m%d-%H%M%S")
77
78
  if data is None:
78
79
  data = path.read_bytes()
79
80
  for n in range(100):
80
- bak = path.with_name(f"{path.name}.bak.{stamp}" + (f".{n}" if n else ""))
81
+ name = f"{path.name}.bak.{stamp}" + (f".{n}" if n else "")
82
+ bak = into / name if into is not None else path.with_name(name)
81
83
  try:
82
84
  fd = os.open(bak, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
83
85
  except FileExistsError:
@@ -309,6 +309,23 @@ def identity_commit(ctx: Context) -> int:
309
309
  return 0
310
310
 
311
311
 
312
+ def commit_verdict(rep, cfg, committer_email: Optional[str], author_email: Optional[str]) -> Dict[str, object]:
313
+ """What the pre-commit identity check (identity_commit above) decides for a commit in rep.repo, without
314
+ printing, recording a denial or running any process. {"refuses": the check returns BLOCK,
315
+ "would_block": it does and [guard] on_mismatch is "block", "mode": on_mismatch}. Team-policy entries
316
+ and the commit-msg trailer check are not run here. GITPRO_SKIP is not read."""
317
+ global _RECORD
318
+ ctx = Context("pre-commit", rep.repo, (), "", cfg, rep, committer_email or None, author_email or None,
319
+ lambda line: None)
320
+ before, _RECORD = _RECORD, False
321
+ try:
322
+ refuses = identity_commit(ctx) == BLOCK
323
+ finally:
324
+ _RECORD = before
325
+ mode = cfg.guard.on_mismatch
326
+ return {"refuses": refuses, "would_block": refuses and mode == "block", "mode": mode}
327
+
328
+
312
329
  def _outgoing(repo: Path, stdin: str) -> List[Tuple[str, str, str, str]]:
313
330
  """(committer email, author email, short sha, subject) of every commit this push sends that no
314
331
  remote-tracking ref has, oldest last as git log prints them."""
@@ -46,7 +46,12 @@ PARSE_ERROR, INVALID_REQUEST, NO_METHOD, INVALID_PARAMS, INTERNAL_ERROR = -32700
46
46
 
47
47
 
48
48
  class ToolError(Exception):
49
- """A gitpro error a tool reports as a result with isError (the server carries on)."""
49
+ """A gitpro error a tool reports as a result with isError (the server carries on). data, when given, is
50
+ the same error object the command's --json prints; the result carries it instead of the message."""
51
+
52
+ def __init__(self, message: str = "", data: Optional[dict] = None):
53
+ super().__init__(message)
54
+ self.data = data
50
55
 
51
56
 
52
57
  class RpcError(Exception):
@@ -152,7 +157,13 @@ def tool_why(args: dict, ctx: Ctx) -> dict:
152
157
  from . import resolve
153
158
  resolve.require_git()
154
159
  repo = _repo(args["path"])
155
- cfg = ctx.config()
160
+ try:
161
+ cfg = ctx.config()
162
+ except ConfigError as e:
163
+ if ctx.cfg_path: # an explicit config is an error, as `why -c PATH --json`
164
+ raise
165
+ # the object `why --json` prints when there is no usable config (shared construction)
166
+ raise ToolError(str(e), _envelope("why", {"ok": False, **resolve.why_error(e, repo)})) from None
156
167
  rep = resolve.compare(repo, cfg, remote=args.get("remote"), home=ctx.home)
157
168
  data = rep.to_json()
158
169
  data.pop("ok")
@@ -666,7 +677,7 @@ class Server:
666
677
  return self.failed(f"internal error: the tool stopped ({type(e).__name__}"
667
678
  + (f" {code}" if code not in (None, 0) else "") + ")")
668
679
  except (ToolError, ConfigError, OSError, UnicodeError) as e:
669
- return self.failed(str(e))
680
+ return self.failed_with(str(e), getattr(e, "data", None))
670
681
  except subprocess.CalledProcessError as e:
671
682
  cmd = e.cmd if isinstance(e.cmd, str) else shlex.join(str(c) for c in e.cmd)
672
683
  return self.failed(f"{cmd} failed ({e.returncode})")
@@ -696,6 +707,17 @@ class Server:
696
707
  def failed(message: str) -> dict:
697
708
  return {"content": [{"type": "text", "text": f"✗ {redact(message)}"}], "isError": True}
698
709
 
710
+ def failed_with(self, message: str, data: Optional[dict]) -> dict:
711
+ """An error result. With data, the command's own error object, as `--json` prints it."""
712
+ if data is None:
713
+ return self.failed(message)
714
+ obj = redact(data)
715
+ out = {"content": [{"type": "text", "text": json.dumps(obj, ensure_ascii=False, allow_nan=False)}],
716
+ "isError": True}
717
+ if self.version >= STRUCTURED_SINCE:
718
+ out["structuredContent"] = obj
719
+ return out
720
+
699
721
 
700
722
  def _error(mid: object, code: int, message: str) -> dict:
701
723
  return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
@@ -755,11 +777,12 @@ def cmd_mcp(args: argparse.Namespace) -> int:
755
777
 
756
778
  # ---------------------------------------------------------------- install / uninstall
757
779
 
758
- AGENTS = ("claude", "claude-desktop", "cursor", "gemini", "opencode")
780
+ AGENTS = ("claude", "claude-desktop", "copilot", "cursor", "gemini", "opencode")
759
781
  ENTRY = {"type": "stdio", "command": "gitpro", "args": ["mcp"]}
760
782
  PLAIN_ENTRY = {"command": "gitpro", "args": ["mcp"]} # formats that document no "type"
761
783
  OPENCODE_ENTRY = {"type": "local", "command": ["gitpro", "mcp"]}
762
- PROJECT_FILES = {"claude": ".mcp.json", "cursor": ".cursor/mcp.json"}
784
+ COPILOT_ENTRY = {"type": "local", "command": "gitpro", "args": ["mcp"], "tools": ["*"]} # "*": all the tools
785
+ PROJECT_FILES = {"claude": ".mcp.json", "copilot": ".github/mcp.json", "cursor": ".cursor/mcp.json"}
763
786
  CLAUDE_ADD = ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "gitpro", "--", "gitpro", "mcp"]
764
787
  CLAUDE_REMOVE = ["claude", "mcp", "remove", "--scope", "user", "gitpro"]
765
788
 
@@ -772,6 +795,8 @@ def _user_target(agent: str, home: Path) -> Tuple[Path, str, dict]:
772
795
  if agent == "claude-desktop":
773
796
  base = home / "Library/Application Support/Claude" if sys.platform == "darwin" else xdg / "Claude"
774
797
  return base / "claude_desktop_config.json", "mcpServers", PLAIN_ENTRY
798
+ if agent == "copilot":
799
+ return home / ".copilot/mcp-config.json", "mcpServers", COPILOT_ENTRY
775
800
  if agent == "cursor":
776
801
  return home / ".cursor/mcp.json", "mcpServers", ENTRY
777
802
  if agent == "gemini":
@@ -792,7 +817,7 @@ def _target(args: argparse.Namespace) -> Tuple[Path, str, dict]:
792
817
  top = resolve.is_repo(Path.cwd())
793
818
  if top is None:
794
819
  raise ConfigError(f"{Path.cwd()}: not a git repository (--scope project edits the repo's {rel})")
795
- return top / rel, "mcpServers", ENTRY
820
+ return top / rel, "mcpServers", COPILOT_ENTRY if args.agent == "copilot" else ENTRY
796
821
 
797
822
 
798
823
  def _ours(value: object, entry: dict) -> bool:
@@ -1005,7 +1030,7 @@ def register(sub, common, confirm) -> None:
1005
1030
  a = actions.add_parser(name, parents=[confirm], help=text)
1006
1031
  a.add_argument("--agent", choices=AGENTS, default="claude", help="which assistant (default: claude)")
1007
1032
  a.add_argument("--scope", choices=("user", "project"), default="user",
1008
- help="user config (default) or the repo's tracked file (claude, cursor; needs --write)")
1033
+ help="user config (default) or the repo's tracked file (claude, copilot, cursor; needs --write)")
1009
1034
  a.add_argument("--json", action="store_true", help="machine-readable output (needs -y)")
1010
1035
  if name == "install":
1011
1036
  a.add_argument("--force", action="store_true",
@@ -843,8 +843,11 @@ def compare(repo: Path, cfg: Config, *, remote: Optional[str] = None,
843
843
  who = _who(exp)
844
844
 
845
845
  if exp.email is None:
846
- out.append(_p("no_identity", f"no identity for {redact_url(first_url) or top}",
847
- None, f"add an identity: gitpro setup --add {_word(str(top))}"))
846
+ if candidates:
847
+ fix = f"add an identity: gitpro setup --add {_word(str(top))}"
848
+ else: # no remote url: no identity pattern can ever match, so only [default] applies
849
+ fix = f"add a name and email under [default] in {_tilde(str(cfg.path)) if cfg.path else 'the config'}"
850
+ out.append(_p("no_identity", f"no identity for {redact_url(first_url) or top}", None, fix))
848
851
 
849
852
  if exp.match.identity is None:
850
853
  for name, url in candidates:
@@ -1008,7 +1011,9 @@ def render_text(rep: Report, cfg: Config) -> str:
1008
1011
  if e.email:
1009
1012
  lines.append(f" expected {label} {e.name or '(no name)'} <{e.email}>")
1010
1013
  else:
1011
- lines.append(f" expected {label} no name/email in [default]; git will refuse to commit")
1014
+ lines.append(f" expected {label} gitpro has no identity for this repo (no name/email in [default]); "
1015
+ + (f"git will use {f.email.value} from {_shown_origin(f.email, top)}" if f.email.value
1016
+ else "git will refuse to commit"))
1012
1017
  lines.append(f" {e.match.rule}")
1013
1018
  if f.email.value or f.name.value:
1014
1019
  src = f.email if f.email.value else f.name
@@ -1053,6 +1058,17 @@ def register(sub, common, confirm) -> None:
1053
1058
  capability("why", "why.config", "why.commit")
1054
1059
 
1055
1060
 
1061
+ def why_error(e: Exception, directory: Path) -> dict:
1062
+ """The object `why --json` (and the MCP `why`) returns when there is no usable config: still an error,
1063
+ but say what git does here (the extension's status bar needs it)."""
1064
+ from . import cli
1065
+ try:
1066
+ data = cli.which_data(Path(directory))
1067
+ except config.ConfigError:
1068
+ data = {} # not a repo: the config error is the one to report
1069
+ return {"error": str(e), "config": None, **data}
1070
+
1071
+
1056
1072
  def cmd_why(args) -> int:
1057
1073
  require_git()
1058
1074
  from . import cli, commitcheck
@@ -1064,12 +1080,7 @@ def cmd_why(args) -> int:
1064
1080
  except config.ConfigError as e:
1065
1081
  if not as_json or args.config:
1066
1082
  raise
1067
- # No usable config: still an error, but say what git does here (the extension's status bar needs it).
1068
- try:
1069
- data = cli.which_data(Path(args.dir))
1070
- except config.ConfigError:
1071
- data = {} # not a repo: the config error is the one to report
1072
- jsonout.emit("why", {"error": str(e), "config": None, **data}, ok=False)
1083
+ jsonout.emit("why", why_error(e, Path(args.dir)), ok=False)
1073
1084
  return 2
1074
1085
  rep = compare(Path(args.dir), cfg, remote=args.remote, home=home)
1075
1086
  if as_json:
@@ -22,9 +22,11 @@ from .config import ConfigError, check
22
22
 
23
23
  Snapshot = Optional[Tuple[bytes, os.stat_result]]
24
24
  # agents.INSTALL_CHOICES + mcp.AGENTS (a test keeps them in step), and none
25
- AGENTS = ("claude", "claude-desktop", "codex", "cursor", "gemini", "opencode", "none")
26
- TITLE = {"claude": "Claude Code", "claude-desktop": "Claude Desktop", "codex": "Codex", "cursor": "Cursor",
27
- "gemini": "Gemini CLI", "opencode": "opencode"}
25
+ AGENTS = ("claude", "claude-desktop", "codex", "copilot", "cursor", "gemini", "opencode", "none")
26
+ TITLE = {"claude": "Claude Code", "claude-desktop": "Claude Desktop", "codex": "Codex",
27
+ "copilot": "GitHub Copilot CLI", "cursor": "Cursor", "gemini": "Gemini CLI", "opencode": "opencode"}
28
+ # Agents that gitpro has no hook or MCP registration for, and why (shown when one is asked for by name).
29
+ NO_COVERAGE = {"codex": "gitpro has no hook or MCP registration for Codex"}
28
30
  QUESTION = "Apply these changes?"
29
31
  LEARN = "Look through your repos to find your identities?"
30
32
  LEARN_BUDGET = 20.0 # seconds for inspecting the repos found
@@ -362,9 +364,14 @@ class Work:
362
364
  mcps: List[Tuple[str, str]] = field(default_factory=list)
363
365
  done: List[str] = field(default_factory=list) # already in place
364
366
  notes: List[str] = field(default_factory=list) # left alone, and why
367
+ coverage: List[dict] = field(default_factory=list) # per agent: hook and MCP status (see inspect)
368
+ uncovered: List[str] = field(default_factory=list) # agents asked for by name that gitpro cannot cover
369
+ skills: List[dict] = field(default_factory=list) # installed gitpro skill copies: {"path","state"}
370
+ skill_stale: list = field(default_factory=list) # skillcopy.Copy that differ from the bundled skill
371
+ skill_text: Optional[bytes] = None # the bundled skill
365
372
 
366
373
  def pending(self) -> bool:
367
- return bool(self.config or self.keys or self.changes or self.hooks or self.mcps)
374
+ return bool(self.config or self.keys or self.changes or self.hooks or self.mcps or self.skill_stale)
368
375
 
369
376
 
370
377
  def _empty(cfg: config.Config) -> bool:
@@ -405,8 +412,35 @@ def _agents(args, home: Path) -> List[str]:
405
412
  return ["claude"] if (home / ".claude").is_dir() or shutil.which("claude") else []
406
413
 
407
414
 
408
- def inspect(cfg: config.Config, env, names: List[str]) -> Work:
409
- """What the files, the guard and the agents still need; reads only."""
415
+ def _skills(w: Work, home: Path) -> None:
416
+ """Which installed gitpro skill copies differ from the one bundled with this gitpro (spec 23 section 5).
417
+ Reads only. A copy that is not there is not reported: setup never creates one."""
418
+ from . import skillcopy
419
+ w.skill_text = skillcopy.bundled()
420
+ if w.skill_text is None:
421
+ w.notes.append("the gitpro skill is not bundled with this install; installed copies not checked")
422
+ return
423
+ for c in skillcopy.copies(home, w.skill_text):
424
+ w.skills.append({"path": str(c.path), "state": c.state})
425
+ if c.state == "differs":
426
+ w.skill_stale.append(c)
427
+ elif c.state == "unreadable":
428
+ w.notes.append(f"{c.path}: the gitpro skill is there but cannot be read; left alone")
429
+ else:
430
+ w.done.append(f"gitpro skill {undo_tilde(c.path)}")
431
+
432
+
433
+ def undo_tilde(path: Path) -> str:
434
+ from . import undo
435
+ return undo.tilde(path)
436
+
437
+
438
+ def inspect(cfg: config.Config, env, names: List[str], explicit: bool = False) -> Work:
439
+ """What the files, the guard and the agents still need; reads only. For each agent in names, w.coverage
440
+ has {"agent", "hook", "mcp"[, "reason"]}, each status one of "installed" (gitpro's entry is in the
441
+ agent's config file), "plugin" (a plugin that ships it is enabled), "missing" (setup would add it) or
442
+ "unavailable". None of these says the agent loaded it. With explicit (the user named the agents), an
443
+ agent with neither a hook nor an MCP server available goes to w.uncovered."""
410
444
  from . import agents, keys, mcp, plan
411
445
  w = Work()
412
446
  for k in keys.inventory(cfg.default, list(cfg.identities)):
@@ -418,6 +452,7 @@ def inspect(cfg: config.Config, env, names: List[str]) -> Work:
418
452
  "setup does not create a key over it")
419
453
  else:
420
454
  w.keys.append(k)
455
+ _skills(w, env.home)
421
456
  w.guard, why = _guard(cfg, env)
422
457
  if why:
423
458
  w.notes.append(why)
@@ -430,22 +465,41 @@ def inspect(cfg: config.Config, env, names: List[str]) -> Work:
430
465
  title = TITLE[a]
431
466
  if a == "claude" and agents.plugin_enabled(env.home):
432
467
  w.done.append("Claude Code: the gitpro plugin provides the hook and the MCP server")
468
+ w.coverage.append({"agent": a, "hook": "plugin", "mcp": "plugin"})
433
469
  continue
434
- parts = [("hook", agents.hook_change, w.hooks)] if a in agents.HOOK_AGENTS else []
435
- parts += [("MCP server", mcp.install_change, w.mcps)] if a in mcp.AGENTS else []
436
- if not parts:
437
- w.notes.append(f"{title}: gitpro has no hook or MCP entry for it; the commit guard still checks "
438
- "its commits")
439
- for what, change, todo in parts:
470
+ row = {"agent": a}
471
+ reasons: List[str] = []
472
+ for what, key, change, todo, known in (("hook", "hook", agents.hook_change, w.hooks, agents.HOOK_AGENTS),
473
+ ("MCP server", "mcp", mcp.install_change, w.mcps, mcp.AGENTS)):
474
+ if a not in known:
475
+ row[key] = "unavailable"
476
+ continue
440
477
  try:
441
478
  line = change(a, env.home)
442
479
  except ConfigError as e:
480
+ row[key] = "unavailable"
481
+ reasons.append(f"{what} not set up: {e}")
443
482
  w.notes.append(f"{title} {what} not set up: {e}")
444
483
  continue
445
484
  if line:
485
+ row[key] = "missing"
446
486
  todo.append((a, line))
447
487
  else:
488
+ row[key] = "installed"
448
489
  w.done.append(f"{title} {what}")
490
+ if row["hook"] == "unavailable" and row["mcp"] == "unavailable":
491
+ why = NO_COVERAGE.get(a) or "; ".join(reasons) or f"gitpro has no hook or MCP entry for {title}"
492
+ reasons = [why]
493
+ if explicit:
494
+ w.uncovered.append(f"{title}: {why}; the commit guard still checks its commits")
495
+ else:
496
+ w.notes.append(f"{title}: {why}; the commit guard still checks its commits")
497
+ elif a not in agents.HOOK_AGENTS or a not in mcp.AGENTS:
498
+ reasons.append("gitpro has no " + ("hook" if a not in agents.HOOK_AGENTS else "MCP registration")
499
+ + f" for {title}")
500
+ if reasons:
501
+ row["reason"] = "; ".join(reasons)
502
+ w.coverage.append(row)
449
503
  return w
450
504
 
451
505
 
@@ -474,6 +528,8 @@ def rows(w: Work, env, passphrase: bool) -> List[Tuple[str, str]]:
474
528
  "for the right identity)"))
475
529
  rows += [(f"{TITLE[a]} hook", f"{line} (checks the git commands it runs)") for a, line in w.hooks]
476
530
  rows += [(f"{TITLE[a]} MCP", f"{line} (read-only identity tools)") for a, line in w.mcps]
531
+ rows += [("gitpro skill", f"update {undo.tilde(c.path)} to this gitpro's version (a backup is kept)")
532
+ for c in w.skill_stale]
477
533
  order = ["config", "SSH key", "Git, SSH", "guard"]
478
534
  rows.sort(key=lambda r: order.index(r[0]) if r[0] in order else len(order)) # stable: agents keep theirs
479
535
  return rows
@@ -497,6 +553,8 @@ def show(out, w: Work, env, passphrase: bool) -> None:
497
553
  out.print(Text("Already in place: " + "; ".join(w.done), style="dim"), soft_wrap=True)
498
554
  for n in w.notes:
499
555
  out.print(Text("⚠ " + n, style="yellow"), soft_wrap=True)
556
+ for n in w.uncovered:
557
+ out.print(Text("✗ " + n, style="red"), soft_wrap=True)
500
558
 
501
559
 
502
560
  def _apply_files(cfg: config.Config, env, guard: bool) -> None:
@@ -513,6 +571,34 @@ def _apply_files(cfg: config.Config, env, guard: bool) -> None:
513
571
  print(f"↩ undo: sh {undo.tilde(script)}")
514
572
 
515
573
 
574
+ def _apply_skills(w: Work) -> int:
575
+ """Update the stale skill copies: each is written through its link (never replacing it), after a backup
576
+ in the state folder: a file beside SKILL.md would sit in the skills folder, which may be a synced
577
+ or committed store. Returns the number that failed."""
578
+ from . import files, undo, view
579
+ failed = 0
580
+ lines: List[str] = []
581
+ for c in w.skill_stale:
582
+ try:
583
+ current = c.path.read_bytes()
584
+ if c.old is None or current.decode("utf-8") != c.old:
585
+ raise OSError("it changed since it was checked")
586
+ into = undo.state_dir() / "skill-backups"
587
+ into.mkdir(mode=0o700, exist_ok=True)
588
+ bak = files._backup(c.path, current, into=into)
589
+ lines.append(f"cp -p {shlex.quote(str(bak))} {shlex.quote(str(c.path))}")
590
+ files.atomic_write(c.path, w.skill_text or b"", c.mode)
591
+ except (OSError, UnicodeDecodeError) as e:
592
+ failed += 1
593
+ view.error(f"{c.path}: skill not updated: {e}")
594
+ continue
595
+ view.ok(f"updated {undo.tilde(c.path)} (backup: {undo.tilde(bak)})")
596
+ if lines:
597
+ script = undo.write("setup", lines, what="gitpro skill copies")
598
+ print(f"↩ undo: sh {undo.tilde(script)}")
599
+ return failed
600
+
601
+
516
602
  def _identity_json(i, d) -> dict:
517
603
  return {"id": i.id, "name": i.name, "email": i.email, "host": i.host, "owners": list(i.owners),
518
604
  "ssh_alias": i.ssh_alias, "ssh_key": i.ssh_key, "gh_account": i.gh_account,
@@ -529,12 +615,12 @@ def state(args, home: Path, path: Path, cfg: Optional[config.Config], names: Lis
529
615
  todo = [{"group": "config", "what": f"no identities in {undo.tilde(path)} yet: run gitpro setup in a terminal"}]
530
616
  if args.json:
531
617
  jsonout.emit("setup", {"dry_run": True, "config": str(path), "identities": [], "default": None,
532
- "pending": todo, "done": [], "notes": []}, ok=False)
618
+ "pending": todo, "done": [], "notes": [], "agents": [], "uncovered": [], "skills": []}, ok=False)
533
619
  else:
534
620
  print(f"Config: {path}")
535
621
  view.warn(todo[0]["what"])
536
622
  return 1
537
- w = inspect(cfg, env, names)
623
+ w = inspect(cfg, env, names, explicit=bool(args.agent))
538
624
  pending = [{"group": g, "what": what} for g, what in rows(w, env, passphrase=not args.no_passphrase)]
539
625
  warnings = list(lint.warnings(cfg.default, list(cfg.identities), env))
540
626
  if args.json:
@@ -544,8 +630,9 @@ def state(args, home: Path, path: Path, cfg: Optional[config.Config], names: Lis
544
630
  "identities": [_identity_json(i, d) for i in cfg.identities],
545
631
  "default": {"name": d.name, "email": d.email, "gh_account": d.gh_account, "glab_account": d.glab_account,
546
632
  "clone_dir": d.clone_dir, "sign": d.sign},
547
- "pending": pending, "done": w.done, "notes": w.notes + warnings}, ok=not pending)
548
- return 1 if pending else 0
633
+ "pending": pending, "done": w.done, "notes": w.notes + warnings, "agents": w.coverage,
634
+ "uncovered": w.uncovered, "skills": w.skills}, ok=not (pending or w.uncovered))
635
+ return 1 if pending or w.uncovered else 0
549
636
  out = view.console()
550
637
  out.print(Text(f"Config: {cfg.path}"))
551
638
  for identity in cfg.identities:
@@ -561,6 +648,8 @@ def state(args, home: Path, path: Path, cfg: Optional[config.Config], names: Lis
561
648
  if pending:
562
649
  view.step("to do it: gitpro setup")
563
650
  return 1
651
+ if w.uncovered:
652
+ return 1
564
653
  view.ok("this machine is set up; nothing to change")
565
654
  return 0
566
655
 
@@ -619,14 +708,14 @@ def cmd_setup(args) -> int:
619
708
  out.print(Syntax(text[len(original):] if added else text, "toml", word_wrap=True))
620
709
 
621
710
  env = plan.Env(home)
622
- w = inspect(cfg, env, names)
711
+ w = inspect(cfg, env, names, explicit=bool(args.agent))
623
712
  if text != original:
624
713
  w.config = (f"create {undo.tilde(path)}" if before is None else
625
714
  f"update {undo.tilde(path)} (a backup is kept)")
626
715
  show(out, w, env, passphrase=not args.no_passphrase)
627
716
  failed = 0
628
717
  if not w.pending():
629
- view.ok("this machine is set up; nothing to change")
718
+ view.ok("nothing to change" if w.uncovered else "this machine is set up; nothing to change")
630
719
  else:
631
720
  out.print(Text("Repos are not changed: 'gitpro scan' lists the ones that need a fix.", style="dim"))
632
721
  check(not w.keys or args.no_passphrase or tty,
@@ -656,8 +745,9 @@ def cmd_setup(args) -> int:
656
745
  for a, _ in w.mcps:
657
746
  failed += mcp.cmd_install(argparse.Namespace(
658
747
  agent=a, scope="user", home=home, json=False, yes=True, write=True, force=False)) != 0
748
+ failed += _apply_skills(w)
659
749
  if failed:
660
- view.error("an agent step failed (above); run gitpro setup again after fixing it")
750
+ view.error("a step failed (above); run gitpro setup again after fixing it")
661
751
  for msg in lint.warnings(cfg.default, list(cfg.identities), env):
662
752
  view.warn(msg)
663
753
  inv = keys.inventory(cfg.default, list(cfg.identities))
@@ -665,4 +755,4 @@ def cmd_setup(args) -> int:
665
755
  out.print()
666
756
  keys.report(inv, env.expand)
667
757
  view.step("check your repos: gitpro scan (it changes nothing; it shows each fix)")
668
- return 1 if failed else 0
758
+ return 1 if failed or w.uncovered else 0
@@ -0,0 +1,65 @@
1
+ """The gitpro skill that ships inside the package, and the copies of it that agents already have.
2
+
3
+ `setup -n` reports an installed copy that differs from the bundled one; `setup -y` updates the copies that
4
+ exist and never creates one (spec 23 section 5). Standard library only."""
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from dataclasses import dataclass, field
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+
13
+ # Where agents look for a user-level skill, relative to the home folder. ~/.claude/skills and
14
+ # ~/.copilot/skills are often links to ~/.agents/skills: one physical file counts once.
15
+ LOCATIONS = (".claude/skills/gitpro", ".copilot/skills/gitpro", ".agents/skills/gitpro")
16
+ NAME = "SKILL.md"
17
+ PACKAGED = "skill/SKILL.md" # inside the wheel (pyproject force-include)
18
+
19
+
20
+ def bundled() -> Optional[bytes]:
21
+ """The skill of this gitpro: the packaged copy, else (a source checkout) skills/gitpro/SKILL.md."""
22
+ try:
23
+ return resources.files("gitpro").joinpath(PACKAGED).read_bytes()
24
+ except (OSError, ModuleNotFoundError):
25
+ pass
26
+ try:
27
+ return (Path(__file__).resolve().parents[2] / "skills" / "gitpro" / NAME).read_bytes()
28
+ except OSError:
29
+ return None
30
+
31
+
32
+ @dataclass
33
+ class Copy:
34
+ path: Path # the physical file (symlinks resolved)
35
+ locations: List[Path] = field(default_factory=list) # the paths under home that lead to it
36
+ state: str = "current" # current | differs | unreadable
37
+ old: Optional[str] = None # its text, when it differs
38
+ mode: int = 0o644
39
+
40
+
41
+ def copies(home: Path, want: Optional[bytes]) -> List[Copy]:
42
+ """The installed copies under home, each physical file once, in LOCATIONS order. A location with no
43
+ SKILL.md (or a dangling link) is not a copy. Reads only."""
44
+ found: dict = {}
45
+ for loc in LOCATIONS:
46
+ link = home / loc / NAME
47
+ try:
48
+ if not link.is_file():
49
+ continue
50
+ real = Path(os.path.realpath(link))
51
+ except OSError:
52
+ continue
53
+ c = found.get(real)
54
+ if c is None:
55
+ c = found[real] = Copy(real)
56
+ try:
57
+ data = real.read_bytes()
58
+ c.mode = real.stat().st_mode & 0o777
59
+ if want is not None and data != want:
60
+ c.state = "differs"
61
+ c.old = data.decode("utf-8")
62
+ except (OSError, UnicodeDecodeError):
63
+ c.state = "unreadable"
64
+ c.locations.append(link)
65
+ return list(found.values())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes