@@ -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)
|
||||
@@ -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` → 把签名块发回让用户点「验证」。
|
||||
@@ -0,0 +1,173 @@
|
||||
# Gitea:SSH / 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
|
||||
```
|
||||
|
||||
## 四、Token(HTTPS 推送)
|
||||
|
||||
与密钥验证令牌不同:这是 **访问令牌**(设置 → 应用)。
|
||||
|
||||
- 用户名: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` 等)
|
||||
@@ -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` 解析 URL;HTTPS 静默注入 Token;SSH 则 `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 Manager,stdout 不含 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
|
||||
@@ -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.'
|
||||
@@ -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
@@ -0,0 +1,10 @@
|
||||
.git-skills/
|
||||
*.dpapi
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
id_rsa
|
||||
id_ed25519
|
||||
id_ecdsa
|
||||
*.token
|
||||
token.txt
|
||||
@@ -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
@@ -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`)
|
||||
- GPG(Gpg4win,或 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
@@ -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/GPG;stdout 不含 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` | 仅 Token,DPAPI `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
|
||||
```
|
||||
|
||||
HTTPS:Token 解密后仅进入 `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
@@ -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)
|
||||
@@ -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 仓库环境。
|
||||
|
||||
```
|
||||
```
|
||||
Reference in New Issue
Block a user