@@ -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 是当前用户刚做的提交、且尚未推送。
|
||||
Reference in New Issue
Block a user