初始化: 完成Git Skill测试基线

Co-authored-by: 旅行呀~ <travelxiao@qq.com>
This commit is contained in:
2026-08-20 23:42:02 +08:00
commit 2cddf06b6c
30 changed files with 3422 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
---
name: git-init
description: >-
Complete Git workflows on Windows for Gitea (and other hosts): init, signed
Chinese commits, push, clone, pull, fetch, branch, merge, tag, stash, and
Gitea SSH/GPG key verification challenges, using a local author profile,
DPAPI-encrypted access token, SSH, and GPG. Use when the user asks to
初始化 Git, git init, Gitea, 验证密钥, 验证SSH, 验证GPG, 找不到此签名对应的密钥,
提交, commit, 推送, push, 克隆, clone, 拉取, pull, fetch, 分支, branch, 合并,
merge, 标签, tag, stash, 远程推送, 配置远程仓库, 配置 SSH, 配置 GPG,
配置 Git Token, or uses the git-init skill.
---
# git-init
Windows PowerShell. Default remote host is **Gitea** (self-hosted or public). Do not use `eval "$(ssh-agent -s)"`. See [gitea.md](gitea.md) for URL forms, Token, SSH, and GPG on Gitea.
Resolve `scripts/`:
1. Workspace `.cursor/skills/git-init/scripts` if it exists
2. Else `$env:USERPROFILE/.cursor/skills/git-init/scripts`
3. Else tell the user to run `install.ps1` from the git-skills repo and stop
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/<name>.ps1"
```
Secrets live in `%USERPROFILE%/.git-skills/` (never in the project).
## Security red lines
- Never read, print, or log `token.dpapi` or the **HTTPS access token**
- Never put the HTTPS access token in chat, argv, README, `.env`, or git config
- Never ask the user to paste the **HTTPS access token** into the conversation; use `store-token.ps1` on their machine
- **Exception (Gitea key verify only):** you MAY ask for the short-lived **challenge token** shown on `/user/settings/keys` → 验证 (GPG and/or SSH). Do not store it. Do not confuse it with the access token
- Do not run `store-token.ps1` or `store-profile.ps1` in the agent session
- Never `--force` / `--force-with-lease` unless the user explicitly asked
- Never `--no-verify` / skip hooks; never change an existing remote
- Never set `--global` `user.name` / `user.email`. `credential.helper` may be global
- Commit only when the user asked to commit/提交; push only when they asked to push/推送
- Never `reset --hard`, `rebase -i`, `push --delete`, or rewrite history unless the user explicitly asked
## Route
| User intent | Path |
| --- | --- |
| 初始化 / git init / 配置远程 / 首次提交 | **Init** |
| 提交 / commit | **Commit** (do not re-run init) |
| 推送 / push / 远程推送 | **Push** |
| 提交并推送 | **Commit** then **Push** |
| 克隆 / clone | **Clone** — [workflows.md](workflows.md) |
| 拉取 / pull / fetch | **Sync** — [workflows.md](workflows.md) |
| 分支 / 切换 / 新建分支 | **Branch** — [workflows.md](workflows.md) |
| 合并 / merge / rebase | **Merge** — [workflows.md](workflows.md) |
| 标签 / tag | **Tag** — [workflows.md](workflows.md) |
| stash / 暂存改动 | **Stash** — [workflows.md](workflows.md) |
| 状态 / log / diff / 现在 git 怎么样 | **Inspect** (`repo-status.ps1`) |
| 验证密钥 / 验证 SSH / 验证 GPG / 找不到此签名对应的密钥 / 开锁 | **Verify keys** (below) |
If the folder is not a repo and the user asked to commit/push/pull/branch, run **Init** first (ask for remote URL if missing). Clone does not init the current folder.
Examples: [examples.md](examples.md).
## Shared: profile + token
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/profile-status.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/token-status.ps1"
```
- Profile `missing` → stop; user runs `store-profile.ps1` in their own terminal
- Token `missing`/`invalid` → required for **Init** and for **HTTPS Push**; for **SSH Push** continue without token. User runs `store-token.ps1` in their own terminal when needed
Do not ask for name/email/HTTPS access token in chat if the profile exists.
# Verify keys (Gitea SSH + GPG)
Proactive path when the user wants key verification, or Init finished adding keys, or UI shows 「找不到此签名对应的密钥」.
```
- [ ] Explain: open /user/settings/keys, click 验证 (challenge token ≠ access token)
- [ ] Ask for GPG challenge token (and Key ID if unknown)
- [ ] Ask for SSH challenge token
- [ ] Run verify-gitea-keys.ps1 with tokens received
- [ ] Show signature blocks; user pastes back into Gitea → 验证
- [ ] Remind: hard-refresh; only new commits show verified
```
**Ask in chat (required, do not wait for the user to invent the step):**
1. GPG: 「请打开 Gitea → 设置 → SSH/GPG 密钥 → 对应 GPG → 点验证,把页面上的令牌发给我(这是一次性挑战令牌,不是访问令牌)」
2. SSH: 「请再打开同一页的 SSH 密钥 → 点验证,把**另一个**页面令牌发给我」
When tokens arrive:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/ensure-gpg.ps1" -Name "<name>" -Email "<email>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/verify-gitea-keys.ps1" -GpgToken "<gpg-page-token>" -SshToken "<ssh-page-token>" -KeyId "<key_id>"
```
- Only GPG: `-GpgOnly -GpgToken "..."`
- Only SSH: `-SshOnly -SshToken "..."`
- Missing token → script prints `need_token` / `ask=...`; ask again and stop until answered
Then paste the printed `BEGIN PGP SIGNATURE` / `BEGIN SSH SIGNATURE` blocks back to the user and tell them to click **验证** on Gitea. Do not claim verification succeeded until they confirm the UI shows 已验证.
After both verified, ensure local signing:
```powershell
git config user.signingkey "<key_id>"
git config commit.gpgsign true
git config --unset gpg.format
```
Details: [gitea.md](gitea.md).
# Init
```
- [ ] Detect
- [ ] Profile + token
- [ ] Remote URL + SSH/GPG
- [ ] git init + local author
- [ ] origin (do not overwrite)
- [ ] Inject credentials
- [ ] SSH
- [ ] GPG
- [ ] .gitignore + README
- [ ] First signed Chinese commit
- [ ] Push main
- [ ] Verify
```
Detect:
```powershell
git rev-parse --is-inside-work-tree
git remote -v
```
If `origin` already exists: report remotes, switch to **Commit**/**Push** if that is what they wanted, and stop init. Do not change remotes.
If the user did not give a remote URL, ask and wait.
Default: reuse `~/.ssh/id_ed25519` and an existing GPG key. Generate only when missing.
```powershell
git init -b main # skip if already a repo
```
Local author from `profile-status.ps1`:
```powershell
git config user.name "<name>"
git config user.email "<email>"
git remote add origin "<url>"
git config --global credential.helper manager # only if unset
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/inject-credential.ps1" -RemoteUrl "<url>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/ensure-ssh.ps1" -Email "<email>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/test-ssh.ps1" -RemoteUrl "<url>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/ensure-gpg.ps1" -Name "<name>" -Email "<email>"
git config user.signingkey "<key_id>"
git config commit.gpgsign true
```
Show the **public** SSH key. Tell the user to add it in Gitea: **设置 → SSH / GPG 密钥**. Run `test-ssh.ps1` (supports custom SSH ports from the URL). Status `ok` even if ssh exits 1 when the message contains `Hi there` / `successfully authenticated` / `Welcome to Gitea`. If auth fails, wait for the user to add the pubkey.
**Gitea key verification is required** (official: unverified keys → 「找不到此签名对应的密钥」). After keys are added, go to **Verify keys**: **proactively ask** for the GPG and SSH challenge tokens from `/user/settings/keys` → 验证, then run `verify-gitea-keys.ps1`. Prefer OpenPGP for commits (`user.signingkey` = Key ID; do not set `gpg.format=ssh` unless the user wants SSH commit signing). Guide: [gitea.md](gitea.md). Official: https://docs.gitea.com/administration/signing/
Ensure `.gitignore` contains:
```
.git-skills/
*.dpapi
.env
.env.*
*.pem
id_rsa
id_ed25519
id_ecdsa
```
Create `README.md` only if missing (项目简介 / 基础使用说明 / 项目结构 / 开发说明). Do not overwrite.
Then **Commit** with message `初始化: 完成项目Git配置` if there is no commit yet, then **Push** `main` (`git branch -M main` first). Verify with the Push/Commit verify steps.
# Commit (every time)
Do this for the first commit and every later commit. Do not re-init.
```
- [ ] prepare-commit
- [ ] GPG key if signing=missing
- [ ] status / diff / log
- [ ] assert-no-secrets
- [ ] Stage related files only
- [ ] Signed Chinese commit
- [ ] Verify signature
- [ ] Push only if asked
```
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-commit.ps1"
```
If `signing=missing`:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/ensure-gpg.ps1" -Name "<name>" -Email "<email>"
git config user.signingkey "<key_id>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-commit.ps1"
```
Then:
```powershell
git status
git diff
git diff --staged
git log -5 --oneline
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/assert-no-secrets.ps1"
```
If `assert-no-secrets` is `blocked`, unstage those paths and do not commit them.
Stage **only related files**. Do not `git add -A` on later commits unless the user asked to commit everything. Init may `git add -A` after `.gitignore` is in place.
If there is nothing to commit, stop. Do not create an empty commit.
Message must be Chinese `类型: 描述`. Types: `初始化` / `新增` / `修复` / `优化` / `文档`. Infer from the diff; if the user gave a message, rewrite it into this format. Forbidden: `update` / `fix` / `test` / `修改一下`. Details: [reference.md](reference.md).
**Every commit must include `Co-authored-by` trailers** from the local profile (primary author always; plus optional `coAuthors`). Do not invent co-authors. Prefer profile author over any IDE default. If Cursor also appends `Co-authored-by: Cursor <...>`, keep the profile trailers from `emit-commit-message.ps1` (do not drop them). Build the message with:
```powershell
$subject = "类型: 描述"
$msg = & powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/emit-commit-message.ps1" -Subject $subject | Out-String
git commit -S -m $msg.TrimEnd()
```
Equivalent shape (must keep a blank line before trailers):
```
类型: 描述
Co-authored-by: 旅行呀~ <travelxiao@qq.com>
```
Always `-S`. Never strip `Co-authored-by` lines. Never `--amend` unless the user asked and the HEAD commit is yours, unpushed, and no hook failed.
```powershell
git status
git log --show-signature -1
git log -1 --format=%B
```
Confirm the body contains `Co-authored-by:` and author is `姓名 <邮箱>` from the profile. If the user also asked to push, continue to **Push**.
# Push (every time)
Do this for the first push and every later push. Do not re-init. Do not commit during Push unless the user also asked to commit.
```
- [ ] prepare-push
- [ ] Push current branch
- [ ] Verify
```
```powershell
git status -sb
git remote -v
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-push.ps1"
```
If `remote_missing`, ask for a URL and go to **Init** step “Add origin”, then retry. If HTTPS `token_missing`, stop and tell the user to run `store-token.ps1`.
```powershell
git branch --show-current
git rev-parse --abbrev-ref --symbolic-full-name '@{u}'
```
- No upstream: `git push -u origin HEAD`
- Upstream exists: `git push`
- After init, `main` may not exist yet: `git branch -M main` then `git push -u origin main`
- Never `--force` unless explicitly requested (warn that it rewrites remote history)
- Never push to a different remote/branch than the user named
```powershell
git status -sb
git log --show-signature -1
```
# Clone / Sync / Branch / Merge / Tag / Stash / Inspect
Read [workflows.md](workflows.md) and follow that path. Network ops always run auth first:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/repo-status.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-auth.ps1" -RemoteUrl "<url>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-push.ps1"
```
- **Clone**: `prepare-auth` then `git clone`; then `prepare-commit` inside the clone (do not commit unless asked)
- **Pull**: `prepare-push` then `git pull --ff-only`. If it fails, stop and report divergence; do not force
- **Inspect**: `repo-status.ps1` plus `git status` / `diff` / `log --show-signature`; read-only
## Additional resources
- Gitea URLs, Token, SSH/GPG verify: [gitea.md](gitea.md)
- Commit types, Windows SSH/GPG, troubleshooting: [reference.md](reference.md)
- Clone, pull, branch, merge, tag, stash, inspect: [workflows.md](workflows.md)
- Trigger examples: [examples.md](examples.md)
+66
View File
@@ -0,0 +1,66 @@
# git-init 触发示例
默认远程为 **Gitea**。把主机名换成你的实例。
## 初始化
用户:用 git-init 初始化,远程是 `git@git.example.com:owner/repo.git`
或:`https://git.example.com:3000/owner/repo.git`
或:`ssh://git@git.example.com:2222/owner/repo.git`
路径:Init → 首次 Commit → Push main
## 日常提交
用户:按 git-init 提交这些改动
路径:Commit(不 init)。说明含中文 `类型: 描述` + 档案中的 `Co-authored-by`
## 日常推送
用户:推送到远程 / 推送到 Gitea
路径:Push
## 提交并推送
用户:提交并推送
路径:Commit → Push
## 克隆
用户:用 git-init 克隆 `https://git.example.com/owner/repo.git`
路径:Clone`prepare-auth.ps1``git clone``prepare-commit.ps1`
## 拉取
用户:拉取远程更新
路径:Fetch/Pull`--ff-only`
## 分支
用户:基于当前分支新建 `feature/login` 并切过去
路径:Branch `git switch -c feature/login`
## 合并
用户:把 `feature/login` 合进 `main`
路径:Merge
## 查看
用户:现在 git 状态怎么样
路径:Inspect`repo-status.ps1`,不提交)
## Gitea 密钥验证(网页开锁 / 找不到签名密钥)
用户:验证 Gitea 的 SSH 和 GPG / 提交显示找不到签名对应的密钥
路径:**Verify keys** — Agent 主动询问两个页面挑战令牌 → `verify-gitea-keys.ps1` → 把签名块发回让用户点「验证」。
+173
View File
@@ -0,0 +1,173 @@
# GiteaSSH / GPG 配置与验证
依据官方文档与 Gitea UI 源码整理(用户侧)。管理员侧签名见文末链接。
- 官方:[GPG/SSH Commit Signatures](https://docs.gitea.com/administration/signing/)
- 密钥页:`https://<你的Gitea>/user/settings/keys`
- 信任模型(管理员):`[repository.signing] DEFAULT_TRUST_MODEL``collaborator` / `committer` / `collaboratorcommitter`
## 网页图标含义(官方)
| 图标 | 含义 |
| --- | --- |
| 灰色开锁 | **数据库中找不到**可用于校验的密钥(中文常见:「找不到此签名对应的密钥」) |
| 红色开锁 | 声称已签名,但密钥有问题 / 不可信 |
| 已验证锁 | 签名可被 Gitea 用库中**已验证**密钥校验,且符合仓库信任模型 |
**只「添加」公钥不够。** SSH 用于提交签名识别、以及 GPG 显示已验证,通常都要求在设置页完成 **验证**(证明持有对应私钥)。见 [go-gitea/gitea#20597](https://github.com/go-gitea/gitea/issues/20597)。
## 远程 URL
```
https://git.example.com/owner/repo.git
https://git.example.com:3000/owner/repo.git
git@git.example.com:owner/repo.git
ssh://git@git.example.com:2222/owner/repo.git
```
HTTPS 非默认端口会写入 GCM `host=name:port`。详见 Skill 脚本 `Parse-GitRemoteUrl`
## 一、SSH 密钥(推送 + 可选 SSH 签名)
### 1. 本机生成 / 复用
Skill`ensure-ssh.ps1`(默认 `~/.ssh/id_ed25519`)。
### 2. 添加到 Gitea
1. **设置****SSH / GPG 密钥****增加密钥**
2. 粘贴**公钥**`.pub` 一整行)
3. 保存
### 3. 验证 SSH 密钥(必须,否则 SSH 签名提交会「找不到密钥」)
1. 在该密钥旁点 **验证**
2. 复制页面令牌(每次不同,勿写入仓库)
3. 官方 UI 给出的命令形态(见 `keys_ssh.tmpl`):
```bash
echo -n 'TOKEN' | ssh-keygen -Y sign -n gitea -f /path/to/id_ed25519
```
Windows 推荐用 Skill 脚本(避免 PowerShell/`echo` 编码问题):
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/verify-ssh-challenge.ps1" `
-Token "TOKEN_FROM_PAGE" `
-KeyPath "$env:USERPROFILE\.ssh\id_ed25519"
```
4. 将输出的 `-----BEGIN SSH SIGNATURE-----` … 整段贴回页面 → **验证**
5. 成功后应出现「已验证」标记
也可用公钥路径(agent 持有私钥时):`-KeyPath` 指向 `.pub` 亦可(OpenSSH 会经 agent 签名)。
### 4. 测试推送认证(与「签名验证」不同)
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/test-ssh.ps1" -RemoteUrl "<SSH仓库URL>"
```
## 二、GPG 密钥(本 Skill 默认的提交签名方式)
### 1. 本机生成 / 复用
Skill`ensure-gpg.ps1`。导出公钥:
```powershell
& "$env:ProgramFiles\Git\usr\bin\gpg.exe" --armor --export KEY_ID
```
### 2. 添加到 Gitea
1. **设置****SSH / GPG 密钥** → GPG 区 **增加密钥**
2. 粘贴 ASCII 公钥(`BEGIN PGP PUBLIC KEY BLOCK`
3. 保存;确认 **匹配身份** 含 Gitea 账号已验证邮箱(如 `travelxiao@qq.com`
### 3. 验证 GPG 密钥(必须)
官方 UI 命令形态(见 `keys_gpg.tmpl`):
```bash
echo "TOKEN" | gpg -a --default-key KEY_ID --detach-sig
```
注意:这里是 **带换行**`echo "TOKEN"`(与 SSH 的 `echo -n` 不同)。
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/verify-gpg-challenge.ps1" `
-Token "TOKEN_FROM_PAGE" `
-KeyId "5996DA789B43D451"
```
`-----BEGIN PGP SIGNATURE-----` … 贴回 → **验证**
### 4. 本地 Git 使用同一把 GPG 钥签名
```powershell
git config user.signingkey KEY_ID # 仓库 local;或按需 --global
git config commit.gpgsign true
# 使用 GPG 时不要设置 gpg.format=ssh
git config --unset gpg.format # 若曾设为 ssh
```
Skill 的 `prepare-commit.ps1` / Init 流程会设置 local `user.signingkey``commit.gpgsign`
## 三、排错:「找不到此签名对应的密钥」
按官方说明,灰色开锁 = Gitea **库中没有**可用来校验该签名的密钥。常见原因:
1. 公钥已添加但 **未验证** → 先完成上一节验证
2. 本地 `user.signingkey` / `gpg.format` 与网页上的密钥不是同一把
- GPG`gpg.format` 未设或为 openpgp`signingkey` = 网页 Key ID
- SSH 签名:`gpg.format=ssh`,且对应 SSH 密钥已在 Gitea **验证**
3. 提交作者邮箱不在 Gitea 账号邮箱列表 / 与 GPG uid 不一致 → 在 Gitea 添加并验证该邮箱
4. 仓库 **信任模型**`DEFAULT_TRUST_MODEL`)导致虽能识别密钥但不显示为可信(管理员配置;默认常为 `collaborator`
5. 验证成功后请 **硬刷新**Ctrl+F5);**旧提交**可能仍显示开锁,新提交才变绿
检查本机:
```powershell
git config --get user.signingkey
git config --get gpg.format
git config --get user.email
git log -1 --show-signature
```
## 四、TokenHTTPS 推送)
与密钥验证令牌不同:这是 **访问令牌**(设置 → 应用)。
- 用户名:Gitea 登录名(档案 `gitUsername`
- 密码:访问令牌
- 本机:`store-token.ps1`DPAPI),禁止发到对话
## 五、Agent 流程(密钥验证 — 主动询问)
HTTPS **访问令牌**仍禁止进对话。Gitea 页面上的 **验证挑战令牌**(一次性)允许 Agent 主动索取。
1. `ensure-ssh.ps1` / `ensure-gpg.ps1` → 用户把公钥加到 Gitea
2. Agent **主动询问**
- GPG 验证页令牌
- SSH 验证页令牌(与 GPG 不同)
3. 收到后执行:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/verify-gitea-keys.ps1" `
-GpgToken "<gpg>" -SshToken "<ssh>" -KeyId "<key_id>"
```
4. 把生成的签名块发给用户,指导贴回 Gitea → **验证**
5. 用户确认「已验证」后,本地 `commit.gpgsign` + `user.signingkey`
6. `test-ssh.ps1` 测推送通道
缺令牌时脚本输出 `need_token` / `waiting_for_tokens`Agent 继续追问,不要跳过。
## 六、管理员参考(非本机 Skill 必做)
服务器可用 `[repository.signing]` 让 Gitea 自己签 merge 等提交;见官方文档。用户侧密钥验证不依赖该项,但信任模型会影响「已验证」展示。
官方文档:
- https://docs.gitea.com/administration/signing/
- https://docs.gitea.com/administration/config-cheat-sheet/`DEFAULT_TRUST_MODEL` 等)
+192
View File
@@ -0,0 +1,192 @@
# git-init 参考
SKILL.md 未覆盖的细节。Agent 只在需要时阅读本文件。Gitea 见 [gitea.md](gitea.md);克隆/拉取/分支/合并见 [workflows.md](workflows.md)。
## 中文提交规范
所有提交信息必须使用中文。
格式:
```
类型: 描述
Co-authored-by: 姓名 <邮箱>
```
每次提交**必须**带 `Co-authored-by`Git trailer)。主体与 trailer 之间空一行。
- 默认:档案中的 `userName` / `userEmail`(例如 `Co-authored-by: 旅行呀~ <travelxiao@qq.com>`
- 额外合作者:档案字段 `coAuthors``store-profile.ps1` 可追加 `Name <email>`
- 生成完整说明:`emit-commit-message.ps1 -Subject "类型: 描述"`
示例:
```
初始化: 完成项目Git配置
Co-authored-by: 旅行呀~ <travelxiao@qq.com>
```
```
新增: 添加用户模块
Co-authored-by: 旅行呀~ <travelxiao@qq.com>
```
禁止:
```
update
fix
test
修改一下
```
后续提交作者(`git` Author)必须为本地档案中的 `姓名 <邮箱>`,且说明里含对应 `Co-authored-by`。每次提交必须包含清晰描述,例如:
```
新增: 添加支付接口
Co-authored-by: 旅行呀~ <travelxiao@qq.com>
```
签名提交使用 `git commit -S`。仓库 local 配置 `commit.gpgsign=true` 后,普通 `git commit` 也会签名。
## 日常提交
每次提交都走 `prepare-commit.ps1`,不要重新 `git init`
1. `prepare-commit.ps1` 把档案中的 `user.name` / `user.email` 写到**当前仓库**,并打开 `commit.gpgsign`;输出 `co_authored_by=`
2. `signing=missing` 时再跑 `ensure-gpg.ps1`,写入 `user.signingkey`
3. `git status` / `git diff` / `git log -5` 后再写中文 `类型: 描述`
4. `assert-no-secrets.ps1` 失败则不得提交匹配路径
5. 后续提交只暂存相关文件,不要默认 `git add -A`
6.`emit-commit-message.ps1` 生成带 `Co-authored-by` 的说明,再 `git commit -S -m ...`
7. `git log -1 --format=%B` 确认含 `Co-authored-by:`
8. 用户没说推送就不要 `git push`
无变更时不要空提交。
## 日常推送
每次推送都走 `prepare-push.ps1`,不要重新初始化。
1.`origin` 解析 URLHTTPS 静默注入 TokenSSH 则 `ssh-add`(不生成新密钥、不打印公钥)
2. 当前分支无上游:`git push -u origin HEAD`
3. 已有上游:`git push`
4. 禁止 `--force`,除非用户明确要求
5. URL 里若带账号密码,脚本输出会打码,Agent 也不得把完整带密 URL 发到对话里
## Windows SSH
不要使用:
```bash
eval "$(ssh-agent -s)"
```
Windows 使用 OpenSSH 服务:
```powershell
Get-Service ssh-agent
Set-Service ssh-agent -StartupType Manual
Start-Service ssh-agent
ssh-add $env:USERPROFILE\.ssh\id_ed25519
```
`ensure-ssh.ps1` 会尝试启动服务并 `ssh-add``Set-Service` / `Start-Service` 在服务被禁用时可能需要管理员权限;失败时仍展示公钥,让用户手动添加。
公钥路径:`<private-key>.pub`。只把公钥贴到 **Gitea → 设置 → SSH / GPG 密钥**。私钥不得出现在聊天或仓库里。
测试(推荐脚本,支持自建端口):
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/test-ssh.ps1" -RemoteUrl "git@git.example.com:owner/repo.git"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/test-ssh.ps1" -RemoteUrl "ssh://git@git.example.com:2222/owner/repo.git"
```
手写等价命令:
```powershell
ssh -T git@git.example.com
ssh -p 2222 -T git@git.example.com
```
Gitea 成功时常见输出含 `Hi there` / `successfully authenticated` / `Welcome to Gitea`,退出码可能为 1。无 shell 权限是正常的。
完整菜单与 Token 步骤见 [gitea.md](gitea.md)。
`ssh-keygen` 不存在:安装 Git for Windows 或 Windows OpenSSH 可选功能。
## GPG
需要本机 `gpg`Gpg4win 或 Git 自带)。`ensure-gpg.ps1` 会按档案姓名/邮箱查找已有密钥;没有则 batch 生成 Ed25519,失败再回退 RSA 4096。生成密钥使用 `%no-protection`(无口令),便于 Cursor 非交互签名。若用户需要口令保护,应自行用 `gpg --full-generate-key` 创建,再把 key id 交给本流程。
列出密钥:
```powershell
gpg --list-secret-keys --keyid-format LONG
```
Git 配置(仅当前仓库):
```powershell
git config user.signingkey KEY_ID
git config commit.gpgsign true
```
将公钥添加到 **Gitea → 设置 → SSH / GPG 密钥**。否则网页上可能不显示已验证,但本地签名仍然有效。详见 [gitea.md](gitea.md)。
## Token 与凭据
Token 只存在 `%USERPROFILE%\.git-skills\token.dpapi`DPAPI 绑定当前 Windows 用户。换机器或换用户无法解密。
- 写入:用户本机运行 `store-token.ps1`(隐藏输入)
- 状态:`token-status.ps1``present` / `missing` / `invalid`
- 注入:`inject-credential.ps1 -RemoteUrl <url>` 通过 `git credential approve` 交给 Git Credential Managerstdout 不含 Token
全局可设:
```powershell
git config --global credential.helper manager
```
HTTPS 远程:用户名为档案中的 `gitUsername`(Gitea 登录名),密码为访问令牌。非默认端口会写入 GCM(如 `host=git.example.com:3000`)。SSH 远程:推送走 SSH 密钥;脚本仍会为同一主机注入 HTTPS 凭据(不含 SSH 端口)。详见 [gitea.md](gitea.md)。
## 故障排查
| 现象 | 处理 |
| --- | --- |
| `profile=missing` | 用户本机运行 `store-profile.ps1` |
| `token=missing` / `invalid` | 用户本机运行 `store-token.ps1``invalid` 表示无法用当前用户 DPAPI 解密) |
| `git_username_missing` | 重新运行 `store-profile.ps1` 并填写 HTTPS 用户名 |
| `git_credential_approve_failed` | 确认已安装 Git for Windows / GCM,并设置 `credential.helper=manager` |
| `ssh_keygen_missing` | 安装 OpenSSH 或 Git for Windows |
| `ssh_agent=error``ssh_add=failed` | 以管理员启用 `ssh-agent` 服务,或用户手动 `ssh-add` |
| `Permission denied (publickey)` | 把 `public_key=` 贴到平台后再 `ssh -T` |
| `gpg_missing` | 安装 [Gpg4win](https://www.gpg4win.org/) 并重新打开终端 |
| `gpg_generate_failed` | 检查 gpg 是否可用;或用户交互生成后再跑 `ensure-gpg.ps1` |
| push 认证失败(HTTPS | 确认 Token 对应该 host 且未过期;重新 `store-token` + `inject-credential` |
| 已有 `origin` | 不要改 remote;改为提交/推送或让用户决定 |
| `git log --show-signature` 无签名 | 确认 `commit.gpgsign=true` 且用了 `-S`GPG 密钥存在 |
| `not_a_git_repo` | 先走初始化,或确认当前目录是仓库 |
| `remote_missing` | 用户提供 URL 后 `git remote add origin` |
| `secret_paths` | 从暂存区移除脚本列出的路径后再提交 |
| `ssh_key_missing`(日常推送) | 走初始化 SSH,或用户指定已有密钥路径 |
| `test-ssh` 失败 | 公钥是否已加到 Gitea;URL 端口是否正确;本机是否已接受 host key |
| HTTPS 自建端口失败 | 确认 URL 含 `:3000` 等端口;GCM 的 host 应为 `host:port` |
| 灰色开锁 / 找不到此签名对应的密钥 | 按官方:库中无可用密钥。完成 Gitea **验证**;对齐 `signingkey`;邮箱一致;见 [gitea.md](gitea.md) |
| GPG 验证签名不被接受 | 必须用 `echo "TOKEN"`(有换行);用 `verify-gpg-challenge.ps1`,勿用 SSH 的 `echo -n` |
| SSH 验证签名不被接受 | 必须用 `echo -n`(无换行);用 `verify-ssh-challenge.ps1` 或页面上的 PowerShell/`cmd` 提示 |
## 硬限制
- 不 force push
- 不修改已有 remote
- 不把 Token、私钥、`.dpapi` 写入仓库
- 不把 Token 放进命令行参数或对话
- 不修改全局 `user.name` / `user.email`
@@ -0,0 +1,50 @@
# Agent-safe: refuse to proceed if secret-looking paths are staged or untracked.
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'not_a_git_repo'
exit 1
}
$patterns = @(
'\.dpapi$',
'(^|/)token\.txt$',
'(^|/)\.env$',
'(^|/)\.env\.',
'\.pem$',
'(^|/)id_rsa$',
'(^|/)id_ed25519$',
'(^|/)id_ecdsa$',
'(^|/)\.git-skills/'
)
$blocked = New-Object System.Collections.Generic.List[string]
$lines = Invoke-GitSkillsNative -FilePath git -ArgumentList @('status', '--porcelain', '-uall')
foreach ($line in $lines) {
if ([string]::IsNullOrWhiteSpace($line) -or $line.Length -lt 4) { continue }
$path = $line.Substring(3).Trim().Trim('"')
$path = $path -replace ' -> .+$', ''
$norm = $path.Replace('\', '/')
foreach ($re in $patterns) {
if ($norm -match $re) {
$blocked.Add($norm) | Out-Null
break
}
}
}
if ($blocked.Count -gt 0) {
Write-StatusLine -Key 'status' -Value 'blocked'
Write-StatusLine -Key 'reason' -Value 'secret_paths'
foreach ($p in $blocked) {
Write-StatusLine -Key 'path' -Value $p
}
exit 1
}
Write-StatusLine -Key 'status' -Value 'ok'
exit 0
+291
View File
@@ -0,0 +1,291 @@
# Shared helpers for git-init skill scripts.
# Dot-source only. Never prints token values.
$ErrorActionPreference = 'Stop'
if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue) {
$PSNativeCommandUseErrorActionPreference = $false
}
# Windows PowerShell 5.1 promotes native stderr to a terminating error when
# $ErrorActionPreference='Stop', so 2>$null alone is not enough. This helper
# runs a native executable with stderr discarded and $LASTEXITCODE preserved.
function Invoke-GitSkillsNative {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[string[]]$ArgumentList = @()
)
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$output = @(& $FilePath @ArgumentList 2>$null | ForEach-Object { "$_" })
return ,$output
}
finally {
$ErrorActionPreference = $prevEap
}
}
function Invoke-GitSkillsNativeWithInput {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)]
[string]$InputText
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $FilePath
$psi.UseShellExecute = $false
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$psi.Arguments = ($ArgumentList | ForEach-Object {
if ($_ -match '\s|"') { '"{0}"' -f ($_ -replace '"', '\"') } else { $_ }
}) -join ' '
$proc = New-Object System.Diagnostics.Process
$proc.StartInfo = $psi
[void]$proc.Start()
foreach ($line in ($InputText -split "`r?`n", 0)) {
$proc.StandardInput.WriteLine($line)
}
$proc.StandardInput.Close()
$stdout = $proc.StandardOutput.ReadToEnd()
$stderr = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
$script:LastNativeExitCode = $proc.ExitCode
if ($stderr) { $null = $stderr }
if ([string]::IsNullOrWhiteSpace($stdout)) { return @() }
return @($stdout -split "`r?`n")
}
Add-Type -AssemblyName System.Security | Out-Null
$script:GitSkillsHome = Join-Path $env:USERPROFILE '.git-skills'
$script:ProfilePath = Join-Path $script:GitSkillsHome 'profile.json'
$script:TokenPath = Join-Path $script:GitSkillsHome 'token.dpapi'
$script:DefaultsPath = Join-Path $PSScriptRoot 'defaults.json'
function Get-GitSkillsDefaults {
if (-not (Test-Path -LiteralPath $script:DefaultsPath)) {
return [pscustomobject]@{
userName = 'Traveler'
userEmail = 'user@example.com'
}
}
return Get-Content -LiteralPath $script:DefaultsPath -Raw -Encoding UTF8 | ConvertFrom-Json
}
function Ensure-GitSkillsHome {
if (-not (Test-Path -LiteralPath $script:GitSkillsHome)) {
New-Item -ItemType Directory -Path $script:GitSkillsHome -Force | Out-Null
}
}
function Get-GitSkillsProfile {
if (-not (Test-Path -LiteralPath $script:ProfilePath)) {
return $null
}
return Get-Content -LiteralPath $script:ProfilePath -Raw -Encoding UTF8 | ConvertFrom-Json
}
function Save-GitSkillsProfile {
param(
[Parameter(Mandatory = $true)]
$Profile
)
Ensure-GitSkillsHome
$json = $Profile | ConvertTo-Json -Depth 5
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::WriteAllText($script:ProfilePath, $json, $utf8Bom)
}
function Protect-SecretString {
param(
[Parameter(Mandatory = $true)]
[string]$PlainText
)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($PlainText)
return [System.Security.Cryptography.ProtectedData]::Protect(
$bytes,
$null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
}
function Unprotect-SecretBytes {
param(
[Parameter(Mandatory = $true)]
[byte[]]$Data
)
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect(
$Data,
$null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
return [System.Text.Encoding]::UTF8.GetString($bytes)
}
function ConvertFrom-SecureStringPlain {
param(
[Parameter(Mandatory = $true)]
[System.Security.SecureString]$Secure
)
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
try {
return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
}
finally {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
}
function Parse-GitRemoteUrl {
param(
[Parameter(Mandatory = $true)]
[string]$RemoteUrl
)
$url = $RemoteUrl.Trim()
if ([string]::IsNullOrWhiteSpace($url)) {
throw 'Remote URL is empty.'
}
function New-ParsedRemote {
param(
[string]$Protocol,
[string]$HostName,
[object]$Port,
[string]$Path
)
$portNum = $null
if ($null -ne $Port -and "$Port" -ne '') {
$portNum = [int]$Port
}
$credentialHost = $HostName
if ($Protocol -eq 'https' -or $Protocol -eq 'http') {
$defaultPort = if ($Protocol -eq 'https') { 443 } else { 80 }
if ($null -ne $portNum -and $portNum -ne $defaultPort) {
$credentialHost = '{0}:{1}' -f $HostName, $portNum
}
}
return [pscustomobject]@{
Protocol = $Protocol
Host = $HostName
Port = $portNum
Path = $Path
CredentialHost = $credentialHost
}
}
# Gitea / Git SCP: git@host:owner/repo.git
if ($url -match '^git@([^:]+):(.+)$') {
return New-ParsedRemote -Protocol 'ssh' -HostName $Matches[1] -Port $null -Path $Matches[2]
}
# ssh://git@host:2222/owner/repo.git or ssh://git@host/owner/repo.git
if ($url -match '^ssh://(?:[^@/]+@)?(\[[^\]]+\]|[^:/]+)(?::(\d+))?/(.+)$') {
$port = if ($Matches[2]) { [int]$Matches[2] } else { $null }
return New-ParsedRemote -Protocol 'ssh' -HostName $Matches[1].Trim('[]') -Port $port -Path $Matches[3]
}
try {
$uri = [Uri]$url
}
catch {
throw "Unsupported remote URL: $url"
}
if (-not $uri.IsAbsoluteUri -or [string]::IsNullOrWhiteSpace($uri.Host)) {
throw "Unsupported remote URL: $url"
}
$port = $null
if (-not $uri.IsDefaultPort) {
$port = $uri.Port
}
return New-ParsedRemote -Protocol $uri.Scheme -HostName $uri.Host -Port $port -Path $uri.AbsolutePath.TrimStart('/')
}
function Write-StatusLine {
param(
[Parameter(Mandatory = $true)]
[string]$Key,
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Value
)
Write-Output ("{0}={1}" -f $Key, $Value)
}
function Get-CoAuthorTrailerLines {
param(
$GitProfile = $null
)
if ($null -eq $GitProfile) {
$GitProfile = Get-GitSkillsProfile
}
if ($null -eq $GitProfile) {
return @()
}
$lines = New-Object System.Collections.Generic.List[string]
$primaryName = ([string]$GitProfile.userName).Trim()
$primaryEmail = ([string]$GitProfile.userEmail).Trim()
if (-not [string]::IsNullOrWhiteSpace($primaryName) -and -not [string]::IsNullOrWhiteSpace($primaryEmail)) {
[void]$lines.Add(('Co-authored-by: {0} <{1}>' -f $primaryName, $primaryEmail))
}
$extras = @()
if ($GitProfile.PSObject.Properties.Name -contains 'coAuthors' -and $null -ne $GitProfile.coAuthors) {
$extras = @($GitProfile.coAuthors)
}
foreach ($item in $extras) {
$name = ''
$email = ''
if ($null -eq $item) { continue }
if ($item -is [string]) {
if ($item -match '^\s*(.+?)\s*<([^>]+)>\s*$') {
$name = $Matches[1].Trim()
$email = $Matches[2].Trim()
}
}
else {
$name = ([string]$item.name).Trim()
$email = ([string]$item.email).Trim()
}
if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($email)) {
continue
}
$line = 'Co-authored-by: {0} <{1}>' -f $name, $email
if (-not $lines.Contains($line)) {
[void]$lines.Add($line)
}
}
return @($lines)
}
function Format-CommitMessageWithTrailers {
param(
[Parameter(Mandatory = $true)]
[string]$Subject,
$GitProfile = $null
)
$subject = $Subject.Trim()
if ([string]::IsNullOrWhiteSpace($subject)) {
throw 'Commit subject is empty.'
}
$trailers = Get-CoAuthorTrailerLines -GitProfile $GitProfile
if ($trailers.Count -eq 0) {
return $subject
}
return ($subject + "`n`n" + ($trailers -join "`n"))
}
@@ -0,0 +1,4 @@
{
"userName": "旅行呀~",
"userEmail": "travelxiao@qq.com"
}
@@ -0,0 +1,24 @@
# Agent-safe: build a commit message with required Co-authored-by trailers.
# Prints the full message only (no key=value). Never prints tokens.
param(
[Parameter(Mandatory = $true)]
[string]$Subject
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$gitProfile = Get-GitSkillsProfile
if ($null -eq $gitProfile) {
Write-Error 'profile_missing'
exit 1
}
$msg = Format-CommitMessageWithTrailers -Subject $Subject -GitProfile $gitProfile
# Use LF so Git trailers parse consistently on Windows.
$msg = $msg -replace "`r`n", "`n"
[Console]::Out.Write($msg)
if (-not $msg.EndsWith("`n")) {
[Console]::Out.Write("`n")
}
@@ -0,0 +1,202 @@
# Detect or generate a GPG signing key. Print key id only. Never export secret keys.
param(
[string]$Name,
[string]$Email,
[switch]$ForceGenerate
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
function Resolve-Gpg {
$cmd = Get-Command gpg -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
$candidates = @(
(Join-Path $env:ProgramFiles 'GnuPG\bin\gpg.exe'),
(Join-Path ${env:ProgramFiles(x86)} 'GnuPG\bin\gpg.exe'),
(Join-Path $env:ProgramFiles 'Git\usr\bin\gpg.exe')
)
foreach ($path in $candidates) {
if ($path -and (Test-Path -LiteralPath $path)) {
return $path
}
}
return $null
}
function Get-SecretKeyForEmail {
param(
[Parameter(Mandatory = $true)]
[string]$GpgPath,
[Parameter(Mandatory = $true)]
[string]$EmailAddress
)
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$lines = Invoke-GitSkillsNative -FilePath $GpgPath -ArgumentList @('--batch', '--list-secret-keys', '--with-colons')
}
finally {
$ErrorActionPreference = $prevEap
}
$currentId = ''
$currentFpr = ''
$matchedId = ''
$matchedFpr = ''
foreach ($line in $lines) {
$parts = $line.Split(':')
if ($parts.Count -lt 2) { continue }
$type = $parts[0]
if ($type -eq 'sec' -or $type -eq 'ssb') {
if ($type -eq 'sec') {
$currentId = $parts[4]
$currentFpr = ''
}
}
elseif ($type -eq 'fpr' -and $parts.Count -ge 10) {
$fpr = $parts[9]
if ([string]::IsNullOrWhiteSpace($currentFpr)) {
$currentFpr = $fpr
if ([string]::IsNullOrWhiteSpace($currentId) -and $fpr.Length -ge 16) {
$currentId = $fpr.Substring($fpr.Length - 16)
}
}
}
elseif ($type -eq 'uid') {
$uid = if ($parts.Count -ge 10) { $parts[9] } else { '' }
if ($uid -match [regex]::Escape($EmailAddress)) {
$matchedId = $currentId
$matchedFpr = $currentFpr
break
}
}
}
if ([string]::IsNullOrWhiteSpace($matchedId)) {
return $null
}
return [pscustomobject]@{
KeyId = $matchedId
Fingerprint = $matchedFpr
}
}
function New-BatchKey {
param(
[Parameter(Mandatory = $true)]
[string]$GpgPath,
[Parameter(Mandatory = $true)]
[string]$RealName,
[Parameter(Mandatory = $true)]
[string]$EmailAddress,
[Parameter(Mandatory = $true)]
[ValidateSet('ed25519', 'rsa4096')]
[string]$Kind
)
if ($Kind -eq 'ed25519') {
$body = @"
%echo generating
Key-Type: EDDSA
Key-Curve: Ed25519
Key-Usage: sign
Subkey-Type: ECDH
Subkey-Curve: Curve25519
Subkey-Usage: encrypt
Name-Real: $RealName
Name-Email: $EmailAddress
Expire-Date: 0
%no-protection
%commit
%echo done
"@
}
else {
$body = @"
%echo generating
Key-Type: RSA
Key-Length: 4096
Key-Usage: sign
Subkey-Type: RSA
Subkey-Length: 4096
Subkey-Usage: encrypt
Name-Real: $RealName
Name-Email: $EmailAddress
Expire-Date: 0
%no-protection
%commit
%echo done
"@
}
$temp = [System.IO.Path]::GetTempFileName()
try {
$utf8 = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($temp, $body, $utf8)
Invoke-GitSkillsNative -FilePath $GpgPath -ArgumentList @('--batch', '--generate-key', $temp) | Out-Null
return ($LASTEXITCODE -eq 0)
}
finally {
if (Test-Path -LiteralPath $temp) {
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
}
}
}
$gitProfile = Get-GitSkillsProfile
$defaults = Get-GitSkillsDefaults
if ([string]::IsNullOrWhiteSpace($Name)) {
if ($gitProfile) { $Name = [string]$gitProfile.userName }
}
if ([string]::IsNullOrWhiteSpace($Email)) {
if ($gitProfile) { $Email = [string]$gitProfile.userEmail }
}
if ([string]::IsNullOrWhiteSpace($Name)) { $Name = [string]$defaults.userName }
if ([string]::IsNullOrWhiteSpace($Email)) { $Email = [string]$defaults.userEmail }
$gpg = Resolve-Gpg
if ($null -eq $gpg) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'gpg_missing'
Write-Output 'hint=Install Gpg4win, then re-run ensure-gpg.ps1'
exit 1
}
$existing = Get-SecretKeyForEmail -GpgPath $gpg -EmailAddress $Email
if ($existing -and -not $ForceGenerate) {
Write-StatusLine -Key 'status' -Value 'exists'
Write-StatusLine -Key 'key_id' -Value $existing.KeyId
Write-StatusLine -Key 'fingerprint' -Value $existing.Fingerprint
Write-StatusLine -Key 'gpg' -Value $gpg
exit 0
}
$created = New-BatchKey -GpgPath $gpg -RealName $Name -EmailAddress $Email -Kind 'ed25519'
if (-not $created) {
$created = New-BatchKey -GpgPath $gpg -RealName $Name -EmailAddress $Email -Kind 'rsa4096'
}
if (-not $created) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'gpg_generate_failed'
exit 1
}
$createdKey = Get-SecretKeyForEmail -GpgPath $gpg -EmailAddress $Email
if ($null -eq $createdKey) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'gpg_key_not_found_after_generate'
exit 1
}
Write-StatusLine -Key 'status' -Value 'created'
Write-StatusLine -Key 'key_id' -Value $createdKey.KeyId
Write-StatusLine -Key 'fingerprint' -Value $createdKey.Fingerprint
Write-StatusLine -Key 'gpg' -Value $gpg
@@ -0,0 +1,114 @@
# Detect or generate ED25519 SSH key. Print public key only. Never print the private key.
param(
[string]$Email,
[string]$KeyPath,
[switch]$ForceGenerate,
[switch]$NoGenerate,
[switch]$Quiet
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$gitProfile = Get-GitSkillsProfile
if ([string]::IsNullOrWhiteSpace($Email)) {
if ($gitProfile) { $Email = [string]$gitProfile.userEmail }
}
if ([string]::IsNullOrWhiteSpace($Email)) {
$Email = [string](Get-GitSkillsDefaults).userEmail
}
if ([string]::IsNullOrWhiteSpace($KeyPath)) {
if ($gitProfile -and $gitProfile.sshKeyPath) {
$KeyPath = [string]$gitProfile.sshKeyPath
} else {
$KeyPath = Join-Path $env:USERPROFILE '.ssh\id_ed25519'
}
}
if (Test-Path -LiteralPath $KeyPath -PathType Container) {
$KeyPath = Join-Path $KeyPath 'id_ed25519'
}
$pubPath = $KeyPath + '.pub'
$sshDir = Split-Path -Parent $KeyPath
if (-not (Test-Path -LiteralPath $sshDir)) {
New-Item -ItemType Directory -Path $sshDir -Force | Out-Null
}
$status = 'exists'
if ($NoGenerate -and -not (Test-Path -LiteralPath $KeyPath)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'ssh_key_missing'
Write-StatusLine -Key 'key_path' -Value $KeyPath
exit 1
}
if ($ForceGenerate -or -not (Test-Path -LiteralPath $KeyPath)) {
if ((Test-Path -LiteralPath $KeyPath) -and $ForceGenerate) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'refusing_to_overwrite_ssh_key'
Write-StatusLine -Key 'key_path' -Value $KeyPath
exit 1
}
$sshKeygen = Get-Command ssh-keygen -ErrorAction SilentlyContinue
if ($null -eq $sshKeygen) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'ssh_keygen_missing'
exit 1
}
Invoke-GitSkillsNative -FilePath ssh-keygen -ArgumentList @('-t', 'ed25519', '-C', $Email, '-f', $KeyPath, '-N', '""') | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'ssh_keygen_failed'
exit 1
}
$status = 'created'
}
if (-not (Test-Path -LiteralPath $pubPath)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'public_key_missing'
Write-StatusLine -Key 'key_path' -Value $KeyPath
exit 1
}
$agent = 'unavailable'
$added = 'skipped'
$svc = Get-Service -Name ssh-agent -ErrorAction SilentlyContinue
if ($null -ne $svc) {
try {
if ($svc.StartType -eq 'Disabled') {
Set-Service -Name ssh-agent -StartupType Manual
}
if ((Get-Service -Name ssh-agent).Status -ne 'Running') {
Start-Service -Name ssh-agent
}
$agent = 'running'
Invoke-GitSkillsNative -FilePath ssh-add -ArgumentList @($KeyPath) | Out-Null
if ($LASTEXITCODE -eq 0) {
$added = 'yes'
} else {
$added = 'failed'
}
}
catch {
$agent = 'error'
$added = 'failed'
}
}
$publicKey = (Get-Content -LiteralPath $pubPath -Raw -Encoding UTF8).Trim()
Write-StatusLine -Key 'status' -Value $status
Write-StatusLine -Key 'key_path' -Value $KeyPath
Write-StatusLine -Key 'public_key_path' -Value $pubPath
Write-StatusLine -Key 'ssh_agent' -Value $agent
Write-StatusLine -Key 'ssh_add' -Value $added
if (-not $Quiet) {
Write-Output ('public_key=' + $publicKey)
}
@@ -0,0 +1,112 @@
# Agent-safe: decrypt the local token and feed it to Git Credential Manager.
# Never prints the token. Never writes it to a file.
param(
[Parameter(Mandatory = $true)]
[string]$RemoteUrl
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$gitProfile = Get-GitSkillsProfile
if ($null -eq $gitProfile) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'profile_missing'
exit 1
}
$username = ([string]$gitProfile.gitUsername).Trim()
if ([string]::IsNullOrWhiteSpace($username)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'git_username_missing'
exit 1
}
if ($username -match "[\r\n]") {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'git_username_invalid'
exit 1
}
if (-not (Test-Path -LiteralPath $script:TokenPath)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'token_missing'
exit 1
}
$parsed = Parse-GitRemoteUrl -RemoteUrl $RemoteUrl
$hostName = [string]$parsed.CredentialHost
if ([string]::IsNullOrWhiteSpace($hostName)) {
$hostName = [string]$parsed.Host
}
if ([string]::IsNullOrWhiteSpace($hostName)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'host_parse_failed'
exit 1
}
$credProtocol = 'https'
if ([string]$parsed.Protocol -eq 'http') {
$credProtocol = 'http'
}
$token = $null
$payload = $null
try {
$data = [System.IO.File]::ReadAllBytes($script:TokenPath)
$token = Unprotect-SecretBytes -Data $data
if ([string]::IsNullOrWhiteSpace($token)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'token_invalid'
exit 1
}
$payload = @"
protocol=$credProtocol
host=$hostName
username=$username
password=$token
"@
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'git'
$psi.Arguments = 'credential approve'
$psi.UseShellExecute = $false
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$proc = New-Object System.Diagnostics.Process
$proc.StartInfo = $psi
[void]$proc.Start()
foreach ($line in ($payload -split "`r?`n")) {
$proc.StandardInput.WriteLine($line)
}
$proc.StandardInput.Close()
$null = $proc.StandardOutput.ReadToEnd()
$null = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
if ($proc.ExitCode -ne 0) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'git_credential_approve_failed'
exit 1
}
}
catch {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'inject_error'
exit 1
}
finally {
$token = $null
$payload = $null
$data = $null
}
Write-StatusLine -Key 'status' -Value 'injected'
Write-StatusLine -Key 'host' -Value $hostName
Write-StatusLine -Key 'protocol' -Value $credProtocol
Write-StatusLine -Key 'username' -Value $username
Write-Output 'token=hidden'
@@ -0,0 +1,74 @@
# Agent-safe: refresh HTTPS/SSH auth for a remote URL. Never prints the token.
# Does not require being inside a git work tree (used by clone).
param(
[Parameter(Mandatory = $true)]
[string]$RemoteUrl,
[string]$RemoteName = ''
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
if ($RemoteUrl -match '@.+@' -or $RemoteUrl -match '://[^/:]+:[^@/]+@') {
$safeUrl = '<redacted-embedded-credentials>'
} else {
$safeUrl = $RemoteUrl.Trim()
}
$parsed = Parse-GitRemoteUrl -RemoteUrl $RemoteUrl
$protocol = [string]$parsed.Protocol
$hostName = [string]$parsed.Host
$helper = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--global', '--get', 'credential.helper') | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($helper)) {
Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--global', 'credential.helper', 'manager') | Out-Null
$helper = 'manager'
}
$tokenPresent = Test-Path -LiteralPath $script:TokenPath
$inject = 'skipped'
if ($tokenPresent) {
& (Join-Path $PSScriptRoot 'inject-credential.ps1') -RemoteUrl $RemoteUrl
if ($LASTEXITCODE -ne 0) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'inject_failed'
exit 1
}
$inject = 'ok'
}
elseif ($protocol -eq 'http' -or $protocol -eq 'https') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'token_missing'
exit 1
}
$ssh = 'skipped'
if ($protocol -eq 'ssh') {
$email = ''
$gitProfile = Get-GitSkillsProfile
if ($gitProfile) { $email = [string]$gitProfile.userEmail }
& (Join-Path $PSScriptRoot 'ensure-ssh.ps1') -Email $email -NoGenerate -Quiet
if ($LASTEXITCODE -ne 0) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'ssh_failed'
exit 1
}
$ssh = 'ok'
}
Write-StatusLine -Key 'status' -Value 'ready'
if (-not [string]::IsNullOrWhiteSpace($RemoteName)) {
Write-StatusLine -Key 'remote' -Value $RemoteName
}
Write-StatusLine -Key 'url' -Value $safeUrl
Write-StatusLine -Key 'protocol' -Value $protocol
Write-StatusLine -Key 'host' -Value $hostName
if ($null -ne $parsed.Port) {
Write-StatusLine -Key 'port' -Value "$($parsed.Port)"
}
Write-StatusLine -Key 'credential_host' -Value ([string]$parsed.CredentialHost)
Write-StatusLine -Key 'credential_helper' -Value $helper
Write-StatusLine -Key 'inject' -Value $inject
Write-StatusLine -Key 'ssh' -Value $ssh
Write-Output 'token=hidden'
@@ -0,0 +1,57 @@
# Agent-safe: apply local author + signing from profile. Does not commit.
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'not_a_git_repo'
exit 1
}
$gitProfile = Get-GitSkillsProfile
if ($null -eq $gitProfile) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'profile_missing'
exit 1
}
$userName = ([string]$gitProfile.userName).Trim()
$userEmail = ([string]$gitProfile.userEmail).Trim()
if ([string]::IsNullOrWhiteSpace($userName) -or [string]::IsNullOrWhiteSpace($userEmail)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'profile_incomplete'
exit 1
}
& git config user.name $userName
& git config user.email $userEmail
& git config commit.gpgsign true
# This skill defaults to OpenPGP signing for Gitea verification (not gpg.format=ssh).
$fmt = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'gpg.format') | Out-String).Trim()
if ($fmt -eq 'ssh') {
Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--unset', 'gpg.format') | Out-Null
}
$signingKey = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.signingkey') | Out-String).Trim()
$gpgSign = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'commit.gpgsign') | Out-String).Trim()
$signing = 'missing'
if (-not [string]::IsNullOrWhiteSpace($signingKey)) {
$signing = 'present'
}
Write-StatusLine -Key 'status' -Value 'ready'
Write-StatusLine -Key 'userName' -Value $userName
Write-StatusLine -Key 'userEmail' -Value $userEmail
Write-StatusLine -Key 'signing' -Value $signing
Write-StatusLine -Key 'signingkey' -Value $signingKey
Write-StatusLine -Key 'gpgsign' -Value $gpgSign
$trailers = Get-CoAuthorTrailerLines -GitProfile $gitProfile
Write-StatusLine -Key 'co_authored_by_count' -Value "$($trailers.Count)"
foreach ($line in $trailers) {
Write-StatusLine -Key 'co_authored_by' -Value $line
}
exit 0
@@ -0,0 +1,26 @@
# Agent-safe: refresh auth for an existing remote (default origin).
param(
[string]$Remote = 'origin'
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'not_a_git_repo'
exit 1
}
$remoteUrl = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('remote', 'get-url', $Remote) | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($remoteUrl)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'remote_missing'
Write-StatusLine -Key 'remote' -Value $Remote
exit 1
}
& (Join-Path $PSScriptRoot 'prepare-auth.ps1') -RemoteUrl $remoteUrl -RemoteName $Remote
exit $LASTEXITCODE
@@ -0,0 +1,23 @@
# Agent-safe: print local author profile fields. Never touches the token.
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$gitProfile = Get-GitSkillsProfile
if ($null -eq $gitProfile) {
Write-StatusLine -Key 'profile' -Value 'missing'
exit 0
}
Write-StatusLine -Key 'profile' -Value 'present'
Write-StatusLine -Key 'path' -Value $script:ProfilePath
Write-StatusLine -Key 'userName' -Value ([string]$gitProfile.userName)
Write-StatusLine -Key 'userEmail' -Value ([string]$gitProfile.userEmail)
Write-StatusLine -Key 'gitUsername' -Value ([string]$gitProfile.gitUsername)
Write-StatusLine -Key 'sshKeyPath' -Value ([string]$gitProfile.sshKeyPath)
$trailers = Get-CoAuthorTrailerLines -GitProfile $gitProfile
Write-StatusLine -Key 'co_authored_by_count' -Value "$($trailers.Count)"
foreach ($line in $trailers) {
Write-StatusLine -Key 'co_authored_by' -Value $line
}
@@ -0,0 +1,52 @@
# Agent-safe repo snapshot. Never prints tokens or private keys.
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') {
Write-StatusLine -Key 'inside' -Value 'false'
exit 0
}
$branch = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('branch', '--show-current') | Out-String).Trim()
$head = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--short', 'HEAD') | Out-String).Trim()
$upstream = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}') | Out-String).Trim()
if ($LASTEXITCODE -ne 0) { $upstream = '' }
$porcelain = @(Invoke-GitSkillsNative -FilePath git -ArgumentList @('status', '--porcelain'))
$dirty = if ($porcelain.Count -gt 0) { 'yes' } else { 'no' }
$remoteUrl = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('remote', 'get-url', 'origin') | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($remoteUrl)) {
$remoteUrl = ''
$safeUrl = ''
$protocol = ''
} else {
if ($remoteUrl -match '@.+@' -or $remoteUrl -match '://[^/:]+:[^@/]+@') {
$safeUrl = '<redacted-embedded-credentials>'
} else {
$safeUrl = $remoteUrl
}
$parsed = Parse-GitRemoteUrl -RemoteUrl $remoteUrl
$protocol = [string]$parsed.Protocol
}
$userName = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.name') | Out-String).Trim()
$userEmail = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.email') | Out-String).Trim()
$gpgSign = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'commit.gpgsign') | Out-String).Trim()
$signingKey = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.signingkey') | Out-String).Trim()
Write-StatusLine -Key 'inside' -Value 'true'
Write-StatusLine -Key 'branch' -Value $branch
Write-StatusLine -Key 'head' -Value $head
Write-StatusLine -Key 'upstream' -Value $upstream
Write-StatusLine -Key 'dirty' -Value $dirty
Write-StatusLine -Key 'origin_url' -Value $safeUrl
Write-StatusLine -Key 'protocol' -Value $protocol
Write-StatusLine -Key 'userName' -Value $userName
Write-StatusLine -Key 'userEmail' -Value $userEmail
Write-StatusLine -Key 'gpgsign' -Value $gpgSign
Write-StatusLine -Key 'signingkey' -Value $signingKey
Write-Output 'token=hidden'
exit 0
@@ -0,0 +1,106 @@
# Interactive: write local author profile (not a secret).
# Run in the user's own terminal, not in an agent session.
param(
[string]$UserName,
[string]$UserEmail,
[string]$GitUsername,
[string]$SshKeyPath
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$defaults = Get-GitSkillsDefaults
function Read-DefaultPrompt {
param(
[string]$Label,
[string]$Default
)
if ([string]::IsNullOrWhiteSpace($Default)) {
$value = Read-Host $Label
return $value.Trim()
}
$value = Read-Host "$Label [$Default]"
if ([string]::IsNullOrWhiteSpace($value)) {
return $Default
}
return $value.Trim()
}
$existing = Get-GitSkillsProfile
# Do not assign to $profile - that is a PowerShell automatic variable.
if (-not $PSBoundParameters.ContainsKey('UserName') -or [string]::IsNullOrWhiteSpace($UserName)) {
$fallback = if ($existing) { $existing.userName } else { $defaults.userName }
$UserName = Read-DefaultPrompt -Label 'Git user.name' -Default $fallback
}
if (-not $PSBoundParameters.ContainsKey('UserEmail') -or [string]::IsNullOrWhiteSpace($UserEmail)) {
$fallback = if ($existing) { $existing.userEmail } else { $defaults.userEmail }
$UserEmail = Read-DefaultPrompt -Label 'Git user.email' -Default $fallback
}
if (-not $PSBoundParameters.ContainsKey('GitUsername') -or [string]::IsNullOrWhiteSpace($GitUsername)) {
$fallback = if ($existing) { $existing.gitUsername } else { '' }
$GitUsername = Read-DefaultPrompt -Label 'Gitea username (HTTPS login name, not the token)' -Default $fallback
}
if (-not $PSBoundParameters.ContainsKey('SshKeyPath') -or [string]::IsNullOrWhiteSpace($SshKeyPath)) {
$fallback = if ($existing -and $existing.sshKeyPath) {
$existing.sshKeyPath
} else {
Join-Path $env:USERPROFILE '.ssh\id_ed25519'
}
$SshKeyPath = Read-DefaultPrompt -Label 'SSH private key path' -Default $fallback
}
$coAuthors = @()
if ($existing -and $existing.PSObject.Properties.Name -contains 'coAuthors' -and $null -ne $existing.coAuthors) {
$coAuthors = @($existing.coAuthors)
}
$extraCo = ''
if (-not [Console]::IsInputRedirected) {
$extraCo = Read-DefaultPrompt -Label 'Extra Co-authored-by as Name <email> (empty keeps current list)' -Default ''
}
if ($extraCo -match '^\s*(.+?)\s*<([^>]+)>\s*$') {
$entry = [pscustomobject]@{
name = $Matches[1].Trim()
email = $Matches[2].Trim()
}
$dup = $false
foreach ($c in $coAuthors) {
if (([string]$c.name) -eq $entry.name -and ([string]$c.email) -eq $entry.email) {
$dup = $true
break
}
}
if (-not $dup) {
$coAuthors += $entry
}
}
if ([string]::IsNullOrWhiteSpace($UserName)) { throw 'user.name is required.' }
if ([string]::IsNullOrWhiteSpace($UserEmail)) { throw 'user.email is required.' }
if ([string]::IsNullOrWhiteSpace($GitUsername)) { throw 'HTTPS Git username is required.' }
$profileObj = [pscustomobject]@{
userName = $UserName
userEmail = $UserEmail
gitUsername = $GitUsername
sshKeyPath = $SshKeyPath
coAuthors = @($coAuthors)
updatedAtUtc = [DateTime]::UtcNow.ToString('o')
}
Save-GitSkillsProfile -Profile $profileObj
Write-StatusLine -Key 'status' -Value 'saved'
Write-StatusLine -Key 'path' -Value $script:ProfilePath
Write-StatusLine -Key 'userName' -Value $UserName
Write-StatusLine -Key 'userEmail' -Value $UserEmail
Write-StatusLine -Key 'gitUsername' -Value $GitUsername
Write-StatusLine -Key 'sshKeyPath' -Value $SshKeyPath
$trailers = Get-CoAuthorTrailerLines -GitProfile $profileObj
Write-StatusLine -Key 'co_authored_by_count' -Value "$($trailers.Count)"
foreach ($line in $trailers) {
Write-StatusLine -Key 'co_authored_by' -Value $line
}
@@ -0,0 +1,42 @@
# Interactive: encrypt a Git token with Windows DPAPI (CurrentUser).
# Run in the user's own terminal. Never print the token.
param(
[switch]$Force
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
Ensure-GitSkillsHome
if ((Test-Path -LiteralPath $script:TokenPath) -and -not $Force) {
$answer = Read-Host 'Encrypted token already exists. Overwrite? (y/N)'
if ($answer -notmatch '^[Yy]$') {
Write-StatusLine -Key 'status' -Value 'unchanged'
exit 0
}
}
$secure = Read-Host 'Paste Gitea access token (input hidden)' -AsSecureString
if ($secure.Length -lt 1) {
throw 'Token is empty.'
}
$plain = $null
try {
$plain = ConvertFrom-SecureStringPlain -Secure $secure
if ([string]::IsNullOrWhiteSpace($plain)) {
throw 'Token is empty.'
}
$protected = Protect-SecretString -PlainText $plain
[System.IO.File]::WriteAllBytes($script:TokenPath, $protected)
}
finally {
$plain = $null
$protected = $null
}
Write-StatusLine -Key 'status' -Value 'saved'
Write-StatusLine -Key 'path' -Value $script:TokenPath
Write-Output 'token=hidden'
@@ -0,0 +1,66 @@
# Agent-safe: test SSH auth against a remote (Gitea-friendly). Never prints private keys.
param(
[Parameter(Mandatory = $true)]
[string]$RemoteUrl
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$parsed = Parse-GitRemoteUrl -RemoteUrl $RemoteUrl
if ([string]$parsed.Protocol -ne 'ssh') {
Write-StatusLine -Key 'status' -Value 'skipped'
Write-StatusLine -Key 'reason' -Value 'not_ssh_remote'
Write-StatusLine -Key 'protocol' -Value ([string]$parsed.Protocol)
exit 0
}
$hostName = [string]$parsed.Host
$port = $parsed.Port
$sshArgs = @()
if ($null -ne $port -and [int]$port -ne 22) {
$sshArgs += @('-p', "$port")
}
$sshArgs += @('-o', 'BatchMode=yes', '-T', "git@$hostName")
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$output = & ssh @sshArgs 2>&1 | Out-String
$code = $LASTEXITCODE
}
finally {
$ErrorActionPreference = $prevEap
}
$text = if ($null -eq $output) { '' } else { [string]$output }
$ok = $false
if ($text -match 'successfully authenticated' -or
$text -match 'Hi there,' -or
$text -match 'Welcome to Gitea' -or
$text -match "You've successfully authenticated") {
$ok = $true
}
# Gitea/GitHub often exit 1 even when auth succeeded (no shell).
if ($ok) {
Write-StatusLine -Key 'status' -Value 'ok'
} else {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'exit_code' -Value "$code"
}
Write-StatusLine -Key 'host' -Value $hostName
if ($null -ne $port) {
Write-StatusLine -Key 'port' -Value "$port"
} else {
Write-StatusLine -Key 'port' -Value '22'
}
# Safe to show SSH server greeting; strip nothing secret-bearing beyond pubkey auth result.
$oneLine = ($text -replace '\r?\n', ' ').Trim()
if ($oneLine.Length -gt 240) { $oneLine = $oneLine.Substring(0, 240) }
Write-StatusLine -Key 'message' -Value $oneLine
if (-not $ok) { exit 1 }
exit 0
@@ -0,0 +1,34 @@
# Agent-safe: report whether an encrypted token exists. Never prints it.
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
if (-not (Test-Path -LiteralPath $script:TokenPath)) {
Write-StatusLine -Key 'token' -Value 'missing'
exit 0
}
$info = Get-Item -LiteralPath $script:TokenPath
if ($info.Length -lt 1) {
Write-StatusLine -Key 'token' -Value 'invalid'
exit 0
}
try {
$data = [System.IO.File]::ReadAllBytes($script:TokenPath)
$plain = Unprotect-SecretBytes -Data $data
if ([string]::IsNullOrWhiteSpace($plain)) {
Write-StatusLine -Key 'token' -Value 'invalid'
exit 0
}
}
catch {
Write-StatusLine -Key 'token' -Value 'invalid'
exit 0
}
finally {
$plain = $null
$data = $null
}
Write-StatusLine -Key 'token' -Value 'present'
@@ -0,0 +1,109 @@
# Orchestrate Gitea SSH + GPG key verification challenges.
# Challenge tokens come from /user/settings/keys Verify page (short-lived).
# Never store them. Never confuse with the HTTPS access token in token.dpapi.
param(
[string]$GpgToken,
[string]$SshToken,
[string]$KeyId,
[string]$KeyPath,
[switch]$GpgOnly,
[switch]$SshOnly
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
function Resolve-DefaultGpgKeyId {
$ensure = Join-Path $PSScriptRoot 'ensure-gpg.ps1'
$gitProfile = Get-GitSkillsProfile
$name = if ($gitProfile) { [string]$gitProfile.userName } else { '' }
$email = if ($gitProfile) { [string]$gitProfile.userEmail } else { '' }
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$out = & powershell -NoProfile -ExecutionPolicy Bypass -File $ensure -Name $name -Email $email 2>&1 | Out-String
}
finally {
$ErrorActionPreference = $prev
}
if ($out -match 'key_id=([0-9A-Fa-f]+)') {
return $Matches[1]
}
return $null
}
$doGpg = -not $SshOnly
$doSsh = -not $GpgOnly
if (-not $doGpg -and -not $doSsh) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'nothing_to_verify'
exit 1
}
$failed = $false
if ($doGpg) {
if ([string]::IsNullOrWhiteSpace($GpgToken)) {
Write-StatusLine -Key 'gpg' -Value 'need_token'
Write-Output 'ask=Open Gitea Settings > SSH/GPG keys > GPG Verify, paste the page challenge token here (NOT the HTTPS access token)'
}
else {
if ([string]::IsNullOrWhiteSpace($KeyId)) {
$KeyId = Resolve-DefaultGpgKeyId
}
if ([string]::IsNullOrWhiteSpace($KeyId)) {
Write-StatusLine -Key 'gpg' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'gpg_key_id_missing'
$failed = $true
}
else {
Write-StatusLine -Key 'gpg_key_id' -Value $KeyId
& (Join-Path $PSScriptRoot 'verify-gpg-challenge.ps1') -Token $GpgToken -KeyId $KeyId
if ($LASTEXITCODE -ne 0) {
$failed = $true
}
else {
Write-Output 'gpg_next=Paste the BEGIN/END PGP SIGNATURE block back into Gitea GPG Verify, then click Verify'
}
}
}
}
if ($doSsh) {
if ([string]::IsNullOrWhiteSpace($SshToken)) {
Write-StatusLine -Key 'ssh' -Value 'need_token'
Write-Output 'ask=Open Gitea Settings > SSH/GPG keys > SSH Verify, paste that page challenge token here (different from GPG token)'
}
else {
$sshInvoke = @{
Token = $SshToken
}
if (-not [string]::IsNullOrWhiteSpace($KeyPath)) {
$sshInvoke['KeyPath'] = $KeyPath
}
& (Join-Path $PSScriptRoot 'verify-ssh-challenge.ps1') @sshInvoke
if ($LASTEXITCODE -ne 0) {
$failed = $true
}
else {
Write-Output 'ssh_next=Paste the BEGIN/END SSH SIGNATURE block back into Gitea SSH Verify, then click Verify'
}
}
}
if ($failed) {
Write-StatusLine -Key 'status' -Value 'failed'
exit 1
}
$gpgPending = $doGpg -and [string]::IsNullOrWhiteSpace($GpgToken)
$sshPending = $doSsh -and [string]::IsNullOrWhiteSpace($SshToken)
if ($gpgPending -or $sshPending) {
Write-StatusLine -Key 'status' -Value 'waiting_for_tokens'
exit 0
}
Write-StatusLine -Key 'status' -Value 'signatures_ready'
Write-Output 'done=Signatures generated. After user pastes them on Gitea and clicks Verify, check new commits for verified status.'
exit 0
@@ -0,0 +1,98 @@
# Generate a Gitea GPG key-verification signature (official UI challenge).
# Token comes from /user/settings/keys -> Verify. Never commit the token.
# Matches Gitea template: echo "TOKEN" | gpg -a --default-key KEYID --detach-sig
# (trailing newline included - unlike SSH verification).
param(
[Parameter(Mandatory = $true)]
[string]$Token,
[Parameter(Mandatory = $true)]
[string]$KeyId,
[string]$GpgPath
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
function Resolve-GpgPath {
param([string]$Preferred)
if ($Preferred -and (Test-Path -LiteralPath $Preferred)) { return $Preferred }
$cmd = Get-Command gpg -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
$candidates = @(
(Join-Path $env:ProgramFiles 'Git\usr\bin\gpg.exe'),
(Join-Path ${env:ProgramFiles(x86)} 'GnuPG\bin\gpg.exe'),
(Join-Path $env:ProgramFiles 'GnuPG\bin\gpg.exe')
)
foreach ($p in $candidates) {
if ($p -and (Test-Path -LiteralPath $p)) { return $p }
}
return $null
}
$gpg = Resolve-GpgPath -Preferred $GpgPath
if ($null -eq $gpg) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'gpg_missing'
exit 1
}
$token = $Token.Trim()
$keyId = $KeyId.Trim()
if ([string]::IsNullOrWhiteSpace($token) -or [string]::IsNullOrWhiteSpace($keyId)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'token_or_key_empty'
exit 1
}
$bash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe'
$sigText = $null
# gpg prints "using ... as default secret key" to stderr; do not treat as terminating error.
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
if (Test-Path -LiteralPath $bash) {
# Unix echo "TOKEN" includes a single trailing LF - matches Gitea UI help.
$sigText = Invoke-GitSkillsNativeWithInput -FilePath $bash -ArgumentList @('-lc', "gpg -a --default-key '$keyId' --detach-sig") -InputText ($token + "`n") | Out-String
} else {
$tmp = [System.IO.Path]::GetTempFileName()
try {
# UTF-8 token + LF, no BOM
$bytes = [System.Text.Encoding]::UTF8.GetBytes($token + "`n")
[System.IO.File]::WriteAllBytes($tmp, $bytes)
$null = Invoke-GitSkillsNative -FilePath $gpg -ArgumentList @('-a', '--default-key', $keyId, '--detach-sig', $tmp)
$asc = $tmp + '.asc'
if (Test-Path -LiteralPath $asc) {
$sigText = Get-Content -LiteralPath $asc -Raw -Encoding UTF8
Remove-Item -LiteralPath $asc -Force -ErrorAction SilentlyContinue
}
}
finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
}
}
finally {
$ErrorActionPreference = $prevEap
}
if ($sigText -notmatch 'BEGIN PGP SIGNATURE') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'sign_failed'
Write-StatusLine -Key 'gpg' -Value $gpg
exit 1
}
Write-StatusLine -Key 'status' -Value 'ok'
Write-StatusLine -Key 'key_id' -Value $keyId
Write-StatusLine -Key 'gpg' -Value $gpg
Write-Output '-----BEGIN_SIGNATURE_BODY-----'
# Print only the armored block lines for easy copy
$capture = $false
foreach ($line in ($sigText -split "`r?`n")) {
if ($line -match 'BEGIN PGP SIGNATURE') { $capture = $true }
if ($capture) { Write-Output $line }
if ($line -match 'END PGP SIGNATURE') { break }
}
Write-Output '-----END_SIGNATURE_BODY-----'
Write-Output 'Paste the block between BEGIN/END PGP SIGNATURE into Gitea Verify, then click verify.'
@@ -0,0 +1,81 @@
# Generate a Gitea SSH key-verification signature (official UI challenge).
# Token from /user/settings/keys -> Verify. Never commit the token.
# Matches: echo -n 'TOKEN' | ssh-keygen -Y sign -n gitea -f KEY
param(
[Parameter(Mandatory = $true)]
[string]$Token,
[string]$KeyPath
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
if ([string]::IsNullOrWhiteSpace($KeyPath)) {
$gitProfile = Get-GitSkillsProfile
if ($gitProfile -and $gitProfile.sshKeyPath) {
$KeyPath = [string]$gitProfile.sshKeyPath
} else {
$KeyPath = Join-Path $env:USERPROFILE '.ssh\id_ed25519'
}
}
$token = $Token.Trim()
if ([string]::IsNullOrWhiteSpace($token)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'token_empty'
exit 1
}
if (-not (Test-Path -LiteralPath $KeyPath)) {
$pub = $KeyPath + '.pub'
if (Test-Path -LiteralPath $pub) {
$KeyPath = $pub
} else {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'key_missing'
Write-StatusLine -Key 'key_path' -Value $KeyPath
exit 1
}
}
$bash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe'
if (-not (Test-Path -LiteralPath $bash)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'git_bash_missing'
Write-Output 'hint=Install Git for Windows, or run the echo -n | ssh-keygen command from the Gitea page in Git Bash'
exit 1
}
# Convert Windows path to Git Bash path: C:\Users\... -> /c/Users/...
$full = [System.IO.Path]::GetFullPath($KeyPath)
$drive = $full.Substring(0, 1).ToLower()
$rest = $full.Substring(2) -replace '\\', '/'
$unixKey = "/$drive$rest"
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$sigText = Invoke-GitSkillsNativeWithInput -FilePath $bash -ArgumentList @('-lc', "ssh-keygen -Y sign -n gitea -f '$unixKey'") -InputText $token | Out-String
}
finally {
$ErrorActionPreference = $prevEap
}
if ($sigText -notmatch 'BEGIN SSH SIGNATURE') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'sign_failed'
Write-StatusLine -Key 'key_path' -Value $KeyPath
exit 1
}
Write-StatusLine -Key 'status' -Value 'ok'
Write-StatusLine -Key 'key_path' -Value $KeyPath
Write-Output '-----BEGIN_SIGNATURE_BODY-----'
$capture = $false
foreach ($line in ($sigText -split "`r?`n")) {
if ($line -match 'BEGIN SSH SIGNATURE') { $capture = $true }
if ($capture) { Write-Output $line }
if ($line -match 'END SSH SIGNATURE') { break }
}
Write-Output '-----END_SIGNATURE_BODY-----'
Write-Output 'Paste the SSH SIGNATURE block into Gitea Verify, then click verify.'
+131
View File
@@ -0,0 +1,131 @@
# 完整 Git 工作流
Agent 在 SKILL.md 路由到这些路径后再读本文件。Windows PowerShell。默认远程为 **Gitea**(见 [gitea.md](gitea.md))。认证一律走 `prepare-auth.ps1` / `prepare-push.ps1`,不得在对话中索取 Token。
先探测:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/repo-status.ps1"
```
## Clone
用户提供 **Gitea** URL(以及可选目录名)。不必已在仓库内。
示例:`https://git.example.com:3000/owner/repo.git``ssh://git@git.example.com:2222/owner/repo.git`
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/profile-status.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-auth.ps1" -RemoteUrl "<url>"
git clone "<url>" "<optional-dir>"
```
进入克隆目录后跑 `prepare-commit.ps1`(只写 local 作者与签名,不提交)。
SSH 且 `ssh_key_missing`:停下来,让用户先走 Init 的 SSH,或改用 HTTPS URL。
Init 后若需验证 SSH
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/test-ssh.ps1" -RemoteUrl "<url>"
```
不要把 URL 里的密码打印到对话。不要 `git clone --recursive` 除非用户要求。不要把示例默认写成 github.com。
## Fetch / Pull
已有仓库。不要在 pull 前擅自 commit。
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-push.ps1"
git fetch origin
```
- 只要拉取远程引用:到此为止
- 用户要更新当前分支:`git pull --ff-only origin`(当前分支已有上游则 `git pull --ff-only`
- `--ff-only` 失败:报告 diverged,询问 merge 还是由用户决定;不要擅自 rebase,不要 `--force`
脏工作区时先报告 `dirty=yes`,问是否先 stash,不要覆盖未提交改动。
## Branch
```powershell
git branch -vv
git status -sb
```
- 列出:上面即可
- 切换:`git switch <name>`(没有则不要乱建)。未提交改动冲突时停下
- 新建并切换:`git switch -c <name>`(用户给了分支名)
- 删除本地分支:仅当用户明确要求,且不是当前分支:`git branch -d <name>`。拒绝 `-D` 除非用户明确要强制删除
- 不要 `push --delete` 远程分支,除非用户明确要求删除远程分支
## Merge
仅当用户要求合并。
```powershell
git status -sb
git switch <target>
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-push.ps1"
git fetch origin
git merge --no-ff <source>
```
冲突:停下,列出冲突文件,不要 `--abort` 除非用户要求放弃。合并提交信息仍用中文 `类型: 描述`(多为 `合并: 将 xx 合入 yy`),需要签名则 `git commit -S`
不要 `rebase -i`。用户明确要求 rebase 时用非交互 `git rebase <onto>`;冲突同样停下。
## Tag
```powershell
git tag -l
```
签名标签(与提交签名同一把 GPG 钥):
```powershell
git tag -s "<tag>" -m @"
:
"@
```
推送标签仅当用户要求:`prepare-push.ps1` 然后 `git push origin "<tag>"`。不要 `git push --tags` 除非用户要推送全部标签。
## Stash
- 暂存:`git stash push -u -m "<中文说明>"`(用户要求保存未提交改动时)
- 列出:`git stash list`
- 恢复:`git stash pop` 仅当用户要求;冲突则停下
- 不要 `stash drop` / `stash clear` 除非明确要求
## Inspect(只读)
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/repo-status.ps1"
git status
git diff
git diff --staged
git log --show-signature -8
git remote -v
```
不要因此提交或推送。不要 `git config --list` 里把 credential 明文贴进对话。
## 安全撤销
允许(用户要求撤销暂存/改动时):
```powershell
git restore --staged -- <paths>
git restore -- <paths>
```
禁止除非用户写明要丢弃历史/覆盖远程:
- `git reset --hard`
- `git checkout --` 覆盖全部
- `git push --force` / `--force-with-lease`
- `git rebase -i``git filter-branch``git push --delete`
`git reset --soft HEAD~1` 仅当:用户明确要求、HEAD 是当前用户刚做的提交、且尚未推送。
+10
View File
@@ -0,0 +1,10 @@
.git-skills/
*.dpapi
.env
.env.*
*.pem
id_rsa
id_ed25519
id_ecdsa
*.token
token.txt
+39
View File
@@ -0,0 +1,39 @@
# git-skills
Cursor 完整 Git Skill,默认对接 **Gitea**:初始化、每次中文签名提交、每次远程推送,以及克隆、拉取、分支、合并、标签、暂存。作者档案与加密 Token 在本机,不进对话、不进仓库。
## 文档
- [使用文档](docs/使用文档.md) — 安装、Gitea Token、在 Cursor 里怎么说
- [架构与流程图](docs/架构.md) — 组件、信任边界、流程图
- [Gitea 说明](.cursor/skills/git-init/gitea.md) — URL / Token / SSH·GPG **添加与验证**(对齐官方文档)
- 官方:[GPG/SSH Commit Signatures](https://docs.gitea.com/administration/signing/)
- [相关文档.md](相关文档.md) — 原始任务说明
## 快速开始
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1
```
本机写入档案与 Gitea 令牌(不要在聊天里贴 Token):
```powershell
$scripts = "$env:USERPROFILE\.cursor\skills\git-init\scripts"
powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\store-profile.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\store-token.ps1"
```
在项目里对 Cursor 说:
```text
用 git-init 初始化,远程是 git@git.example.com:owner/repo.git
```
## 安全
- Token`%USERPROFILE%\.git-skills\token.dpapi`DPAPI,当前用户)
- 不 force push、不改已有 `origin`、不改全局 `user.name` / `user.email`
- 不要安装到 `~\.cursor\skills-cursor\`
修改 Skill 后重新运行 `install.ps1`
+222
View File
@@ -0,0 +1,222 @@
# git-init 使用文档
在 Cursor 里用本 Skill 完成完整 Git 流程。默认远程为 **Gitea**(自建或公网实例):初始化、每次签名提交、每次推送,以及克隆、拉取、分支、合并、标签、暂存。令牌只存在本机并加密,不会出现在对话或仓库里。
配套阅读:
- [架构.md](架构.md) — 组件、数据流、流程图
- [Gitea 说明](../.cursor/skills/git-init/gitea.md) — URL、Token、SSH、GPG 菜单
## 你需要准备什么
| 时机 | 你提供什么 | 不要提供什么 |
| --- | --- | --- |
| 本机一次 | 作者档案 + Gitea 访问令牌(本机终端) | 不要在 Cursor 聊天里粘贴 Token |
| 初始化 | Gitea 仓库 URL;可选 SSH/GPG 偏好 | 不要把私钥或 Token 发给 Agent |
| 日常提交 / 推送 | 说「提交」「推送」或「提交并推送」 | 不必再给 Token |
| 克隆 / 拉取 | Gitea URL 或「拉取远程」 | 不必再给 Token |
默认作者(可在档案里改):`旅行呀~` `<travelxiao@qq.com>`
提交信息必须是中文:`类型: 描述``初始化` / `新增` / `修复` / `优化` / `文档`)。
**每次提交还必须带 `Co-authored-by`**(与档案作者一致,可另加合作者):
```
类型: 描述
Co-authored-by: 旅行呀~ <travelxiao@qq.com>
```
`emit-commit-message.ps1` 自动拼上。额外合作者在 `store-profile.ps1` 里按 `Name <email>` 追加,写入档案的 `coAuthors`
## Gitea 地址示例
```
https://git.example.com/owner/repo.git
https://git.example.com:3000/owner/repo.git
git@git.example.com:owner/repo.git
ssh://git@git.example.com:2222/owner/repo.git
```
HTTPS:用户名 = Gitea 登录名,密码 = **访问令牌**(设置 → 应用 → 生成新令牌)。
SSH / GPG:设置 → SSH / GPG 密钥。
## 安装
在本仓库根目录:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1
```
效果:
- 项目级:`.cursor/skills/git-init`(本仓库直接可用)
- 个人级:`%USERPROFILE%\.cursor\skills\git-init`(所有项目可用)
不要安装到 `~\.cursor\skills-cursor\`。改了 Skill 源码后重新跑一次 `install.ps1`
## 本机一次配置
在**自己的 PowerShell**里运行(不要在对话里贴 Token):
```powershell
$scripts = "$env:USERPROFILE\.cursor\skills\git-init\scripts"
powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\store-profile.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\store-token.ps1"
```
- `store-profile`:姓名、邮箱、**Gitea 用户名**、SSH 私钥路径;可选追加额外 `Co-authored-by``Name <email>`
- `store-token`:隐藏输入 **Gitea 访问令牌**DPAPI 加密到 `%USERPROFILE%\.git-skills\token.dpapi`
检查(可在 Cursor 里跑,不会打印 Token):
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\profile-status.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\token-status.ps1"
```
`token-status` 只会出现 `present` / `missing` / `invalid`
## 在 Cursor 里怎么说
把下面整句发给 Agent(把 URL 换成你的 Gitea 仓库)。
### 初始化
```text
用 git-init 初始化这个仓库,远程是 git@git.example.com:owner/repo.git
```
或:
```text
用 git-init 初始化,远程是 https://git.example.com:3000/owner/repo.git
```
会:`git init`、写本地作者、加 origin、注入凭据、SSH/GPG、补 `.gitignore`、中文签名提交、推送 `main`
### 日常提交
```text
按 git-init 提交这些改动
```
### 日常推送
```text
推送到 Gitea
```
### 提交并推送
```text
提交并推送
```
### 克隆
```text
用 git-init 克隆 https://git.example.com/owner/repo.git
```
### 拉取
```text
拉取远程更新
```
默认 `--ff-only`。历史分叉时会停下问你,不会强推。
### 分支 / 合并 / 查看
```text
基于当前分支新建 feature/login 并切过去
把 feature/login 合进 main
现在 git 状态怎么样
```
更多例句见 [examples.md](../.cursor/skills/git-init/examples.md)。
## 能力一览
| 路径 | 做什么 | 认证 |
| --- | --- | --- |
| Init | 建库、远程、SSH、GPG、首次提交并推送 | Gitea Token + SSH |
| Commit | 每次中文签名提交 + `Co-authored-by` | 本地 GPG |
| Push | 每次推送当前分支 | Token 或 SSH |
| Clone | 克隆 Gitea 仓库 | `prepare-auth` |
| Sync | fetch / pull --ff-only | `prepare-push` |
| Branch / Merge / Tag / Stash / Inspect | 本地协作与只读查看 | 按需联网 |
## 安全约定
- Token 只在 `%USERPROFILE%\.git-skills\token.dpapi`,绑定当前 Windows 用户
- Agent 不得读取该文件,不得让你在聊天里贴 Token
- 不修改已有 `origin`,不改全局 `user.name` / `user.email`
-`--force`、不 `reset --hard`、不跳过 hooks,除非你写明要求
## Gitea 密钥:添加 → 验证(必做)
官方:[GPG/SSH Commit Signatures](https://docs.gitea.com/administration/signing/)。完整步骤见 [gitea.md](../.cursor/skills/git-init/gitea.md)。
网页灰色开锁 /「找不到此签名对应的密钥」= 库中没有**已验证**的可用密钥。只「增加密钥」不够,必须在 `https://<你的Gitea>/user/settings/keys`**验证**
### 验证 GPG(本 Skill 默认用 GPG 签提交)
在 Cursor 里说「验证 Gitea GPG/SSH 密钥」即可。Agent 会**主动问你要页面令牌**,然后生成签名块给你贴回。
也可本机自己跑:
```powershell
$scripts = "$env:USERPROFILE\.cursor\skills\git-init\scripts"
powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\verify-gitea-keys.ps1" `
-GpgToken "GPG页面令牌" `
-SshToken "SSH页面令牌" `
-KeyId "你的GPG_Key_ID"
```
(你当前本机 Key ID 示例:`5996DA789B43D451`,以 `gpg --list-secret-keys --keyid-format LONG` 或 Gitea 页面为准。)
说明:
- **可以**把「验证」页上的一次性挑战令牌发给 Agent(不是访问令牌)
- **不要**把 HTTPS 访问令牌发到对话;访问令牌只用 `store-token.ps1`
- Agent 生成签名后,把 `BEGIN PGP SIGNATURE` / `BEGIN SSH SIGNATURE` 贴回 Gitea → **验证**
- 本地保持:
```powershell
git config user.signingkey <你的GPG_Key_ID>
git config commit.gpgsign true
git config --unset gpg.format # 若曾设为 ssh
```
官方:GPG 用 `echo "TOKEN"`(有换行);SSH 用 `echo -n`(无换行)。验证成功后**新提交**才会显示已验证;旧提交可能仍开锁。硬刷新(Ctrl+F5)再看。
## 本机依赖
- Git for Windows(含 Git Credential Manager、建议用自带 `gpg.exe` / Git Bash
- Windows OpenSSH`ssh-keygen` / `ssh-agent`
- GPGGpg4win,或 Git 自带 `gpg`
## 常见问题
**对话里提示 profile/token missing**
本机跑 `store-profile.ps1` / `store-token.ps1`
**HTTPS 推送失败**
令牌是否过期、是否勾选仓库写权限;自建是否带端口(如 `:3000`);重新 `store-token` 后再推送。
**SSH Permission denied**
公钥是否已加到 Gitea;自定义端口 URL 是否写成 `ssh://git@host:2222/...`。用 `test-ssh.ps1` 测。
**首次连接自建主机问 fingerprint**
在本机终端确认 host key,不要把 Token 发到聊天。
**提交在 Gitea 网页显示「找不到此签名对应的密钥」**
对 Cursor 说「验证 Gitea SSH 和 GPG」。Agent 会主动要两个页面挑战令牌并生成签名块。HTTPS 访问令牌仍不要发到对话。详见 [gitea.md](../.cursor/skills/git-init/gitea.md)。
**改了 Skill 但行为没变**
再跑 `install.ps1`,必要时重开对话。
+326
View File
@@ -0,0 +1,326 @@
# git-init 架构
本文说明组件如何分工、密钥放在哪、各条 Git 路径怎么走。使用步骤见 [使用文档.md](使用文档.md)。
## 设计目标
- **对话里只出现 Gitea 仓库 URL 和 SSH/GPG 选择**,不出现 Token
- **同一套本机档案**服务于初始化以及之后每一次提交、推送、克隆、拉取
- **默认远程为 Gitea**(自建域名与自定义端口);脚本按 URL 解析 host/port
- **Windows PowerShell** 可执行;不用 `eval "$(ssh-agent -s)"`
- **可复用**:源码在仓库 `.cursor/skills/git-init``install.ps1` 同步到个人 Skill 目录
## 逻辑架构
```mermaid
flowchart LR
user[UserCursorChat]
agent[AgentPlusSkill]
scripts[PowerShellScripts]
profile[ProfileJson]
tokenFile[TokenDpapi]
gcm[GitCredentialManager]
sshAgent[WindowsSshAgent]
gpg[GpgKeyring]
git[GitRepo]
remote[GiteaHost]
user --> agent
agent --> scripts
scripts --> profile
scripts --> tokenFile
tokenFile -->|"decrypt CurrentUser"| gcm
scripts --> sshAgent
scripts --> gpg
agent --> git
gcm --> remote
sshAgent --> remote
git --> remote
```
三层:
1. **Skill 指令**`SKILL.md`):路由到 Init / Commit / Push / Clone 等,并列出安全红线
2. **脚本**`scripts/`):状态检查、加密存取、注入凭据、SSH/GPGstdout 不含 Token
3. **本机秘密**`%USERPROFILE%\.git-skills\`):与任何项目仓库隔离
## 目录与职责
```mermaid
flowchart TB
subgraph repo [git-skillsRepo]
skillMd[SKILL.md]
workflows[workflows.md]
reference[reference.md]
examples[examples.md]
scriptsDir[scripts]
installPs[install.ps1]
docsDir[docs]
end
subgraph personal [UserHome]
personalSkill[".cursor/skills/git-init"]
secrets[".git-skills"]
end
installPs -->|"copy skill not secrets"| personalSkill
scriptsDir --> secrets
```
| 路径 | 职责 |
| --- | --- |
| `.cursor/skills/git-init/SKILL.md` | Agent 入口:路由、红线、Init/Commit/Push |
| `workflows.md` | Clone / Pull / Branch / Merge / Tag / Stash / Inspect |
| `reference.md` | 中文提交规范、Windows SSH/GPG、故障表 |
| `examples.md` | 触发例句 |
| `scripts/*.ps1` | 可执行步骤;失败用退出码和 `key=value` |
| `install.ps1` | 复制 Skill 到 `~/.cursor/skills/git-init` |
| `%USERPROFILE%\.git-skills\profile.json` | 姓名、邮箱、HTTPS 用户名、SSH 路径 |
| `%USERPROFILE%\.git-skills\token.dpapi` | 仅 TokenDPAPI `CurrentUser` |
Agent **可以**读 `profile.json`(经 `profile-status.ps1`)。Agent **不得**读 `token.dpapi`
## 信任边界
```mermaid
flowchart TB
subgraph chat [CursorChat]
urlIn[RemoteUrl]
talk[NoTokenInChat]
end
subgraph machine [ThisWindowsUser]
dpapi[DPAPI]
gcm[GCM]
sshKeys[SSHPrivateKey]
gpgKeys[GPGSecretKey]
end
subgraph project [GitWorkTree]
gitDir[.git]
ignore[.gitignore]
end
subgraph host [Gitea]
origin[origin]
end
urlIn --> gitDir
dpapi -->|"git credential approve stdin"| gcm
gcm --> origin
sshKeys --> origin
gitDir --> origin
ignore -->|"block secrets"| gitDir
```
- 解密只在当前 Windows 用户下有效;换用户或换机器无法读 Token
- 注入走 stdin 给 `git credential approve`,不写进命令行参数、不写进仓库
- 项目 `.gitignore` 忽略 `.git-skills/``*.dpapi`、私钥
## 脚本调用关系
```mermaid
flowchart TB
storeProfile[store-profile.ps1]
storeToken[store-token.ps1]
profileStatus[profile-status.ps1]
tokenStatus[token-status.ps1]
prepareCommit[prepare-commit.ps1]
prepareAuth[prepare-auth.ps1]
preparePush[prepare-push.ps1]
inject[inject-credential.ps1]
ensureSsh[ensure-ssh.ps1]
ensureGpg[ensure-gpg.ps1]
assertSecrets[assert-no-secrets.ps1]
repoStatus[repo-status.ps1]
common[common.ps1]
storeProfile --> common
storeToken --> common
profileStatus --> common
tokenStatus --> common
prepareCommit --> common
prepareAuth --> inject
prepareAuth --> ensureSsh
preparePush --> prepareAuth
inject --> common
ensureSsh --> common
ensureGpg --> common
assertSecrets --> common
repoStatus --> common
```
| 脚本 | 谁跑 | 作用 |
| --- | --- | --- |
| `store-profile.ps1` / `store-token.ps1` | 用户本机终端 | 交互写入档案 / 加密 Token |
| `profile-status.ps1` / `token-status.ps1` | Agent | `present` / `missing`,无密钥明文 |
| `prepare-commit.ps1` | Agent | 本地作者 + `commit.gpgsign` |
| `prepare-auth.ps1` | Agent | 按 URL 注入 GCM 或 ssh-add(克隆可用) |
| `prepare-push.ps1` | Agent | 读 `origin` 再调 `prepare-auth` |
| `ensure-ssh.ps1` / `ensure-gpg.ps1` | Agent | 复用或生成密钥;日常推送 `-NoGenerate -Quiet` |
| `assert-no-secrets.ps1` | Agent | 提交前拦截密钥路径 |
| `repo-status.ps1` | Agent | 分支、脏否、打码后的 origin |
## 主流程
### 总路由
```mermaid
flowchart TD
start[UserRequest]
start --> route{Intent}
route -->|init| initPath[Init]
route -->|commit| commitPath[Commit]
route -->|push| pushPath[Push]
route -->|commitAndPush| commitPath
commitPath -->|ifAskedPush| pushPath
route -->|clone| clonePath[Clone]
route -->|pullOrFetch| syncPath[Sync]
route -->|branchMergeTagStash| localPath[LocalGit]
route -->|statusLogDiff| inspectPath[Inspect]
route -->|commitButNotRepo| initPath
```
### 初始化
```mermaid
flowchart TD
detect[DetectRepoAndRemote]
detect --> profile{ProfileAndToken}
profile -->|missing| stopStore[StopTellUserRunStoreScripts]
profile -->|ok| askUrl[NeedRemoteUrl]
askUrl --> initGit[git init plus local author]
initGit --> addOrigin[remote add origin]
addOrigin --> inject[inject-credential]
inject --> ssh[ensure-ssh plus ssh test]
ssh --> gpg[ensure-gpg plus gpgsign]
gpg --> hygiene[gitignore and README]
hygiene --> firstCommit[Commit path]
firstCommit --> firstPush[Push main]
firstPush --> verify[status remote log signature]
```
### 每次提交
```mermaid
flowchart TD
prep[prepare-commit]
prep --> signing{signing present}
signing -->|missing| gpg[ensure-gpg]
gpg --> prep
signing -->|ok| inspect[status diff log]
inspect --> secrets[assert-no-secrets]
secrets -->|blocked| unstage[Unstage secret paths]
secrets -->|ok| stage[Stage related files]
stage --> msg[Chinese type colon description]
msg --> commit[git commit -S]
commit --> check[log show-signature]
check --> maybePush{User asked push}
maybePush -->|yes| pushPath[Push]
maybePush -->|no| done[Stop]
```
### 每次推送 / 克隆 / 拉取
```mermaid
flowchart TD
needAuth[NeedNetworkAuth]
needAuth --> hasRepo{Inside work tree}
hasRepo -->|yes| prepPush[prepare-push]
hasRepo -->|no clone| prepAuth[prepare-auth with URL]
prepPush --> proto{Protocol}
prepAuth --> proto
proto -->|https| gcm[GCM uses injected token]
proto -->|ssh| sshAdd[ssh-add Quiet]
gcm --> gitNet[git push or clone or pull]
sshAdd --> gitNet
```
HTTPSToken 解密后仅进入 `git credential approve` 的 stdin。SSH:用已有 `id_ed25519`,日常路径不生成新密钥、不打印公钥。
## Agent 控制流
```mermaid
sequenceDiagram
participant U as User
participant A as Agent
participant S as Scripts
participant G as Git
participant H as GitHost
U->>A: 提交并推送
A->>S: profile-status token-status repo-status
S-->>A: present plus branch info
A->>S: prepare-commit
A->>S: assert-no-secrets
A->>G: git commit -S
A->>S: prepare-push
S->>G: credential approve or ssh-add
A->>G: git push
G->>H: objects
A-->>U: 作者签名与推送结果无 Token
```
## 与原始任务的对应
[相关文档.md](../相关文档.md) 中的步骤映射:
| 文档章节 | 实现 |
| --- | --- |
| 一 init | `git init -b main` |
| 二 用户信息 | `profile.json` + 仓库 local `user.name` / `user.email` |
| 三 远程 | `git remote add origin`(已有则不改) |
| 四 Token | DPAPI + GCM,禁止写入代码 |
| 五 SSH | `ensure-ssh.ps1` + Windows `ssh-agent` |
| 六 GPG | `ensure-gpg.ps1` + `commit.gpgsign` |
| 七 / 十二 中文提交 | 每次 Commit 路径强制 `类型: 描述` |
| 八 README | 缺失才生成 |
| 九 / 十 首次提交推送 | Init 末尾走 Commit + Push |
| 十一 验收 | `status` / `remote -v` / `log --show-signature` |
## Gitea URL 与凭据
```mermaid
flowchart TD
url[GiteaRemoteUrl]
url --> parse[Parse-GitRemoteUrl]
parse --> httpsPath{Protocol}
httpsPath -->|https or http| credHost[CredentialHost may include port]
httpsPath -->|ssh| sshHost[Host plus optional Port]
credHost --> inject[inject-credential to GCM]
sshHost --> sshTest[test-ssh with ssh -p]
inject --> pushHttps[git push HTTPS]
sshTest --> pushSsh[git push SSH]
```
| URL 示例 | CredentialHost | SSH 测试 |
| --- | --- | --- |
| `https://git.example.com/a/b.git` | `git.example.com` | 不适用 |
| `https://git.example.com:3000/a/b.git` | `git.example.com:3000` | 不适用 |
| `git@git.example.com:a/b.git` | 主机名(供日后 HTTPS) | `ssh -T git@...` |
| `ssh://git@git.example.com:2222/a/b.git` | 主机名(不含 2222 | `ssh -p 2222 -T git@...` |
细节见 [gitea.md](../.cursor/skills/git-init/gitea.md)。
## Gitea 密钥验证(官方行为)
依据 [docs.gitea.com/administration/signing](https://docs.gitea.com/administration/signing/):灰色开锁 = 数据库中找不到可校验密钥。用户密钥须在 `/user/settings/keys` **验证**后,提交签名才能被识别。
```mermaid
flowchart TD
addKey[AddPubkeyOnGitea]
addKey --> verifyClick[ClickVerifyOnSettings]
verifyClick --> challenge[PageShowsToken]
challenge --> localSign[verify-gitea-keys with tokens from chat]
localSign --> paste[AgentShowsSignatureBlocks]
paste --> verified[KeyMarkedVerified]
verified --> newCommit[NewSignedCommitShowsVerified]
```
| 脚本 | 对齐官方 UI |
| --- | --- |
| `verify-gitea-keys.ps1` | 编排:可缺令牌时提示 `need_token`;有令牌则调下面两个脚本 |
| `verify-gpg-challenge.ps1` | `echo "TOKEN" \| gpg -a --default-key KEY --detach-sig`(有换行) |
| `verify-ssh-challenge.ps1` | `echo -n 'TOKEN' \| ssh-keygen -Y sign -n gitea -f KEY`(无换行) |
HTTPS **访问令牌**禁止进对话。页面 **验证挑战令牌**可由 Agent 主动询问(一次性,不落盘)。
+38
View File
@@ -0,0 +1,38 @@
# Install git-init into the personal Cursor skills directory.
# Does not copy tokens, profiles, or SSH/GPG keys.
$ErrorActionPreference = 'Stop'
$source = Join-Path $PSScriptRoot '.cursor\skills\git-init'
$skillsRoot = Join-Path $env:USERPROFILE '.cursor\skills'
$destination = Join-Path $skillsRoot 'git-init'
if (-not (Test-Path -LiteralPath $source)) {
throw "Skill source not found: $source"
}
$reserved = Join-Path $env:USERPROFILE '.cursor\skills-cursor'
if ([IO.Path]::GetFullPath($destination).StartsWith([IO.Path]::GetFullPath($reserved), [StringComparison]::OrdinalIgnoreCase)) {
throw 'Refusing to install into ~/.cursor/skills-cursor (reserved).'
}
if (-not (Test-Path -LiteralPath $skillsRoot)) {
New-Item -ItemType Directory -Path $skillsRoot -Force | Out-Null
}
if (Test-Path -LiteralPath $destination) {
Remove-Item -LiteralPath $destination -Recurse -Force
}
Copy-Item -LiteralPath $source -Destination $destination -Recurse -Force
$profileScript = Join-Path $destination 'scripts\store-profile.ps1'
$tokenScript = Join-Path $destination 'scripts\store-token.ps1'
Write-Output "status=installed"
Write-Output "path=$destination"
Write-Output 'secrets=not_copied'
Write-Output ''
Write-Output 'Next (run in your own terminal, not in chat):'
Write-Output (" powershell -NoProfile -ExecutionPolicy Bypass -File `"{0}`"" -f $profileScript)
Write-Output (" powershell -NoProfile -ExecutionPolicy Bypass -File `"{0}`"" -f $tokenScript)
+349
View File
@@ -0,0 +1,349 @@
````markdown
# Git 仓库初始化配置任务
## 任务目标
你现在作为项目 Git 管理负责人,负责完成当前项目的 Git 初始化、远程仓库配置、开发者信息配置、SSH/GPG 签名配置、首次提交以及推送。
要求:
- 所有 Git 操作规范化
- 提交信息必须使用中文
- 提交作者信息统一
- 配置安全认证
- 完成首次正式提交
---
# 一、Git 初始化
## 初始化仓库
执行:
```bash
git init
````
检查状态:
```bash
git status
```
---
# 二、配置 Git 用户信息
开发者信息:
```
姓名:
旅行呀~
邮箱:
travelxiao@qq.com
```
配置:
```bash
git config user.name "旅行呀~"
git config user.email "travelxiao@qq.com"
```
检查:
```bash
git config --list
```
要求:
提交作者显示:
```
旅行呀~ <travelxiao@qq.com>
```
---
# 三、添加远程仓库
远程仓库地址(Gitea 示例,换成你的主机):
```
git@git.example.com:owner/repo.git
https://git.example.com:3000/owner/repo.git
ssh://git@git.example.com:2222/owner/repo.git
```
添加:
```bash
git remote add origin <Gitea仓库URL>
```
检查:
```bash
git remote -v
```
要求:
```
origin fetch
origin push
```
---
# 四、配置 Git Token
使用 **Gitea 访问令牌** 进行 HTTPS 远程认证。
要求:
* Token 仅保存本地(DPAPI 加密,见 git-init skill
* 不允许写入代码
* 不允许提交到仓库
在 Gitea:设置 → 应用 → 生成新令牌。HTTPS 用户名为 Gitea 登录名,密码为令牌。
配置凭据保存:
```bash
git config --global credential.helper manager
```
首次由 skill 的 `store-token.ps1` / `inject-credential.ps1` 完成,不要在对话中粘贴令牌。
---
# 五、生成 SSH 密钥
生成 ED25519 SSH Key
```bash
ssh-keygen -t ed25519 -C "travelxiao@qq.com"
```
启动(Windows 用 OpenSSH 服务,不要用 eval ssh-agent):
```bash
# Windows PowerShell: Start-Service ssh-agent
eval "$(ssh-agent -s)"
```
添加:
```bash
ssh-add ~/.ssh/id_ed25519
```
查看公钥:
```bash
cat ~/.ssh/id_ed25519.pub
```
将公钥添加到 **Gitea → 设置 → SSH / GPG 密钥**。
测试(把主机换成你的 Gitea;自定义端口用 -p):
```bash
ssh -T git@git.example.com
ssh -p 2222 -T git@git.example.com
```
或使用 skill`test-ssh.ps1 -RemoteUrl <SSH仓库URL>`。
---
# 六、生成 GPG 签名密钥
生成:
```bash
gpg --full-generate-key
```
信息:
```
姓名:
旅行呀~
邮箱:
travelxiao@qq.com
```
查看 Key
```bash
gpg --list-secret-keys --keyid-format LONG
```
配置 Git 签名:
```bash
git config user.signingkey KEY_ID
```
开启提交签名:
```bash
git config commit.gpgsign true
```
将 GPG 公钥添加到 **Gitea → 设置 → SSH / GPG 密钥**(导出:`gpg --armor --export KEY_ID`)。
---
# 七、Git 提交规范
所有提交信息必须使用中文。
格式:
```
类型: 描述
```
示例:
```
初始化: 完成项目Git配置
新增: 添加用户模块
修复: 修复登录问题
优化: 优化代码结构
文档: 更新README
```
禁止:
```
update
fix
test
修改一下
```
---
# 八、完善 README
完善项目根目录:
```
README.md
```
要求:
包含:
* 项目简介
* 基础使用说明
* 项目结构
* 开发说明
---
# 九、第一次提交
添加文件:
```bash
git add .
```
提交:
```bash
git commit -S -m "初始化: 完成项目初始化配置并完善README文档"
```
---
# 十、推送远程仓库
设置主分支:
```bash
git branch -M main
```
推送:
```bash
git push -u origin main
```
---
# 十一、最终检查
执行:
```bash
git status
git remote -v
git log --show-signature -1
```
确认:
* Git 初始化成功
* 用户信息正确
* 远程仓库连接成功
* SSH 配置完成
* GPG 签名成功
* 首次提交完成
* 代码成功推送
---
# 十二、后续提交要求
以后所有提交:
作者:
```
旅行呀~ <travelxiao@qq.com>
```
提交信息:
必须中文。
每次提交必须包含清晰描述:
例如:
```
新增: 添加支付接口
修复: 修复数据异常问题
优化: 优化接口性能
```
目标:
建立规范、安全、可维护的 Git 仓库环境。
```
```