feat(git): 新增提交身份与变更导出 Skill
This commit is contained in:
@@ -61,6 +61,7 @@ plugins/<plugin>/skills/<skill>/
|
||||
- 本地交接默认位于 `.craftkit/local/handoff/current.md`;只有用户明确选择共享时才写入 `.craftkit/handoff/current.md`。
|
||||
- 只在实际需要时创建目录,不一次生成空的 `agents/`、`standards/`、`knowledge/`、`handoff/`、`local/` 或 `cache/`。
|
||||
- Git Skill 默认排除 `.craftkit/local/**` 和 `.craftkit/cache/**`;其他 `.craftkit` 内容按普通项目资产评估,并在提交建议中单独标识为 Agent、规范或知识变更。
|
||||
- Git 交付或变更导出默认排除环境配置、本地配置和机器配置。只有适用的 `AGENTS.md`、`.craftkit/agents/` 或 `.craftkit/standards/` 明确要求交付,并经用户查看预览后再次确认,才可纳入;私钥、真实密钥和检测到的凭据始终禁止导出。
|
||||
- 如果本地目录已经被 Git 跟踪,或整个 `.craftkit/` 被全局规则、`.git/info/exclude` 或项目规则忽略,应提示冲突并停止自动处理;不得自动修改索引或历史。
|
||||
- `.craftkit/` 不得保存凭据、令牌、私钥、个人机器绝对路径或无必要的个人信息。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ CraftKit 是一组面向 Codex 插件市场的中性 Skill 工具。项目从通
|
||||
| --- | --- | --- |
|
||||
| `dev` | 软件设计、编码、审查与测试 | 已初始化,暂无 Skill |
|
||||
| `doc` | 文档转换、整理与写作 | 已迁移 `format-md`、`docx-to-md`、`md-to-docx`、`xlsx-to-md`、`archive` |
|
||||
| `git` | 分支、提交、变更提取与集成 | 已迁移 `commit-msg`、`branch` |
|
||||
| `git` | 分支、提交、变更提取与集成 | 已迁移 `commit-msg`、`branch`、`identity`、`export` |
|
||||
| `knowledge` | 项目初始化、交接、复盘与经验 | 已迁移 `handoff`、`init` |
|
||||
| `skill` | 项目规范及 Skill 创建、迁移与维护 | 已迁移 `guidance` |
|
||||
|
||||
|
||||
@@ -88,10 +88,10 @@
|
||||
|
||||
所属插件:`git`
|
||||
|
||||
1. `create-branch`
|
||||
2. `configure-git`
|
||||
3. `export-changes`
|
||||
4. `integrate-branch`
|
||||
1. `branch`(已完成)
|
||||
2. `identity`(已完成,只管理 Git 提交用户名和邮箱)
|
||||
3. `export`(已完成,环境配置默认排除,规范明确要求并再次确认后才可导出)
|
||||
4. `integrate`(待独立重建,不沿用特定组织的分支和审批模型)
|
||||
|
||||
`commit-msg` 将作为只读 Git 特殊样本先行完成。涉及提交、合并和远端操作的 Skill 必须保留明确授权边界,并保护脏工作区。
|
||||
|
||||
@@ -233,3 +233,4 @@ plan-change
|
||||
- [x] 完成其余特殊样本并总结批量迁移规则。
|
||||
- [ ] 按插件和风险类型继续推进同质批量迁移。
|
||||
- [x] 完成首个同质批量:`md-to-docx`、`xlsx-to-md`、`archive`。
|
||||
- [x] 完成 Git 本地操作批次:`identity`、`export`。
|
||||
|
||||
@@ -246,12 +246,18 @@
|
||||
"source-b:13bb77c66fee0964": {
|
||||
"sourcePathHash": "13bb77c66fee09641fe61781ffbba4c51ed47b14ce225972ea35219e489405a3",
|
||||
"sourceSha256": "abe529dcc85b1ac88791c95cf7be12cf5b1f545988b515f10edb55f0a8c94fb1",
|
||||
"status": "pending"
|
||||
"status": "migrated",
|
||||
"target": "plugins/git/skills/export",
|
||||
"targetVersion": "0.1.0",
|
||||
"reviewedAt": "2026-08-25"
|
||||
},
|
||||
"source-b:fd03e996181699d9": {
|
||||
"sourcePathHash": "fd03e996181699d95a09c1940f685ac366daeeda1aa901175d26dcf153f755fb",
|
||||
"sourceSha256": "b4ef1f7847dd9081fdacaecae8853b2946e22c899b08bbe48ef280d21546e044",
|
||||
"status": "pending"
|
||||
"status": "migrated",
|
||||
"target": "plugins/git/skills/identity",
|
||||
"targetVersion": "0.1.0",
|
||||
"reviewedAt": "2026-08-25"
|
||||
},
|
||||
"source-b:4ca0940d73b4b626": {
|
||||
"sourcePathHash": "4ca0940d73b4b6264a37e1a344d669367dc313364a0077abf8bcaf97cfed42a1",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""验证 Git 变更导出的选择、配置保护和写入边界。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "plugins/git/skills/export/scripts/export.py"
|
||||
|
||||
|
||||
class GitExportTests(unittest.TestCase):
|
||||
"""在临时 Git 仓库中验证真实命令行为。"""
|
||||
|
||||
def git(self, repo: Path, *args: str) -> str:
|
||||
"""执行测试仓库内的 Git 命令。"""
|
||||
|
||||
result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, encoding="utf-8", check=True)
|
||||
return result.stdout.strip()
|
||||
|
||||
def run_export(self, repo: Path, target: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
"""使用当前受控 Python 运行导出脚本。"""
|
||||
|
||||
return subprocess.run([sys.executable, str(SCRIPT), "--repo", str(repo), "--target", str(target), *args], capture_output=True, text=True, encoding="utf-8", check=False)
|
||||
|
||||
def make_repo(self, folder: Path) -> Path:
|
||||
"""创建具有稳定身份和首个提交的隔离仓库。"""
|
||||
|
||||
repo = folder / "repo"; repo.mkdir()
|
||||
self.git(repo, "init", "-q"); self.git(repo, "config", "user.name", "Test User"); self.git(repo, "config", "user.email", "test@example.invalid")
|
||||
(repo / "src").mkdir(); (repo / "src/app.txt").write_text("v1\n", encoding="utf-8")
|
||||
self.git(repo, "add", "src/app.txt"); self.git(repo, "commit", "-q", "-m", "init")
|
||||
return repo
|
||||
|
||||
def test_worktree_preview_classifies_shared_and_protected_files(self) -> None:
|
||||
"""共享 CraftKit 文档可导出,环境配置默认受保护,私钥永久阻断。"""
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); repo = self.make_repo(root); target = root / "out"
|
||||
(repo / "src/app.txt").write_text("v2\n", encoding="utf-8")
|
||||
(repo / "application-dev.yml").write_text("server: local\n", encoding="utf-8")
|
||||
(repo / "private.pem").write_text("-----BEGIN PRIVATE KEY-----\nabc\n", encoding="utf-8")
|
||||
standard = repo / ".craftkit/standards/export.md"; standard.parent.mkdir(parents=True); standard.write_text("规范\n", encoding="utf-8")
|
||||
result = self.run_export(repo, target, "--mode", "worktree", "--snapshot", "worktree", "--include-untracked")
|
||||
self.assertEqual(2, result.returncode)
|
||||
manifest = json.loads(result.stdout)
|
||||
self.assertIn("src/app.txt", manifest["exported"])
|
||||
self.assertIn(".craftkit/standards/export.md", manifest["exported"])
|
||||
self.assertIn("application-dev.yml", manifest["protected"])
|
||||
self.assertIn("private.pem", manifest["blocked"])
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_protected_file_requires_matching_project_evidence(self) -> None:
|
||||
"""环境配置只有在项目规范明确点名且执行确认后才写入。"""
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); repo = self.make_repo(root); target = root / "out"
|
||||
(repo / "application-dev.yml").write_text("server: local\n", encoding="utf-8")
|
||||
evidence = repo / ".craftkit/standards/delivery.md"; evidence.parent.mkdir(parents=True)
|
||||
evidence.write_text("交付必须包含 application-dev.yml。\n", encoding="utf-8")
|
||||
result = self.run_export(repo, target, "--mode", "worktree", "--snapshot", "worktree", "--include-untracked",
|
||||
"--allow-protected", "--policy-evidence", ".craftkit/standards/delivery.md", "--apply")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue((target / "application-dev.yml").is_file())
|
||||
manifest = json.loads((target / "export-manifest.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(".craftkit/standards/delivery.md", manifest["policyEvidence"])
|
||||
|
||||
def test_range_uses_explicit_snapshot_and_records_deleted_file(self) -> None:
|
||||
"""范围负责选路径,快照负责取内容;快照中删除的文件只进入清单。"""
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); repo = self.make_repo(root); target = root / "out"
|
||||
base = self.git(repo, "rev-parse", "HEAD")
|
||||
(repo / "src/app.txt").unlink(); (repo / "src/new.txt").write_text("new\n", encoding="utf-8")
|
||||
self.git(repo, "add", "-A"); self.git(repo, "commit", "-q", "-m", "change")
|
||||
head = self.git(repo, "rev-parse", "HEAD")
|
||||
result = self.run_export(repo, target, "--mode", "range", "--base", base, "--head", head, "--snapshot", head)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
manifest = json.loads(result.stdout)
|
||||
self.assertIn("src/app.txt", manifest["deleted"])
|
||||
self.assertIn("src/new.txt", manifest["exported"])
|
||||
|
||||
def test_time_mode_selects_committed_paths_without_writing(self) -> None:
|
||||
"""时间模式应选择提交中出现的路径,并保持预演只读。"""
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); repo = self.make_repo(root); target = root / "out"
|
||||
(repo / "src/app.txt").write_text("v2\n", encoding="utf-8")
|
||||
self.git(repo, "add", "src/app.txt"); self.git(repo, "commit", "-q", "-m", "update")
|
||||
result = self.run_export(repo, target, "--mode", "time", "--since", "2000-01-01", "--snapshot", "HEAD")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("src/app.txt", json.loads(result.stdout)["exported"])
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "git",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "安全、可复核的通用 Git 工作流。",
|
||||
"author": {
|
||||
"name": "CraftKit"
|
||||
@@ -9,7 +9,7 @@
|
||||
"interface": {
|
||||
"displayName": "Git",
|
||||
"shortDescription": "安全、可复核的 Git 工具",
|
||||
"longDescription": "提供分支、提交、变更提取与集成相关的安全 Git 工作流。",
|
||||
"longDescription": "提供分支创建、提交信息、身份配置和变更导出相关的安全 Git 工作流。",
|
||||
"developerName": "CraftKit",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Read", "Write"],
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: export
|
||||
description: 从 Git 提交范围、时间范围或当前工作区预览并导出变更文件,保留相对目录并生成分类清单。适用于制作代码交付快照;提交、推送、部署或无审查地导出环境配置不应触发本 Skill。
|
||||
---
|
||||
|
||||
# 导出 Git 变更
|
||||
|
||||
使用 `scripts/export.py` 先预演文件分类,再经用户确认写入目标目录。导出不会修改工作区、暂存区、提交或来源文件。
|
||||
|
||||
## 选择模式和快照
|
||||
|
||||
- `range`:两个 Git 引用之间发生变化的文件。
|
||||
- `time`:指定提交时间范围内曾发生变化的文件。
|
||||
- `worktree`:当前 staged、unstaged,以及用户明确要求时的 untracked 文件。
|
||||
|
||||
还必须确认内容快照:工作区、`HEAD` 或指定提交。时间范围只决定“选择哪些文件”,不自动决定“取哪个版本”。详细参数见 [模式说明](references/modes.md)。
|
||||
|
||||
## 环境配置边界
|
||||
|
||||
- 环境配置、本地配置、机器配置、`.craftkit/local/**` 和 `.craftkit/cache/**` 默认标记为 `protected`,不导出。
|
||||
- `.env.example` 等不含真实值的公开模板可作为普通候选,但仍执行内容扫描。
|
||||
- 只有当前项目适用的 `AGENTS.md`、`.craftkit/agents/` 或 `.craftkit/standards/` 明确要求交付该配置,并且用户看到预览后再次确认,才可同时传入 `--allow-protected` 和 `--policy-evidence <项目内文档>`。
|
||||
- 私钥和检测到的凭据始终标记为 `blocked`;项目规范和用户确认都不能解除。
|
||||
- `.craftkit/agents/**`、`.craftkit/standards/**`、`.craftkit/knowledge/**`、`.craftkit/handoff/**` 和 `.craftkit/project.json` 属于普通共享资产。
|
||||
|
||||
## 两阶段执行
|
||||
|
||||
1. 不带 `--apply` 运行,查看 `exported`、`protected`、`blocked`、`deleted` 和 `missing` 分类。
|
||||
2. 展示目标目录、快照引用、规范证据和全部分类;不得只展示将导出的文件。
|
||||
3. 用户确认后,以相同参数增加 `--apply`。目标已存在时默认停止;覆盖必须另外确认并使用 `--force`。
|
||||
4. 检查目标目录中的 `export-manifest.json`,抽查路径与内容来源。
|
||||
|
||||
脚本只调用本地 Git,不执行 fetch、commit、push 或部署。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Export Git Changes"
|
||||
short_description: "预览并安全导出 Git 变更文件和分类清单"
|
||||
default_prompt: "使用 $export 预览这批 Git 变更,排除环境配置后再导出交付快照。"
|
||||
@@ -0,0 +1,21 @@
|
||||
# 导出模式
|
||||
|
||||
```text
|
||||
python scripts/export.py --repo <repo> --target <dir> --mode range --base <base> --head <head> --snapshot <ref>
|
||||
python scripts/export.py --repo <repo> --target <dir> --mode time --since <time> [--until <time>] --snapshot <ref>
|
||||
python scripts/export.py --repo <repo> --target <dir> --mode worktree --snapshot worktree [--include-untracked]
|
||||
```
|
||||
|
||||
预演确认后增加 `--apply`。允许规范明确要求的环境配置时,还需同时增加:
|
||||
|
||||
```text
|
||||
--allow-protected --policy-evidence <AGENTS.md 或 .craftkit 内规范文档>
|
||||
```
|
||||
|
||||
| 模式 | 文件选择依据 | 推荐快照 |
|
||||
| --- | --- | --- |
|
||||
| `range` | `base` 与 `head` 的差异 | `head` 或明确提交 |
|
||||
| `time` | 时间范围内提交曾改动的路径 | `HEAD` 或明确提交 |
|
||||
| `worktree` | 当前相对 `HEAD` 的状态 | `worktree` |
|
||||
|
||||
快照中不存在的路径记为 `deleted`,不会创建空文件。重命名按快照中的新路径导出。
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""预览并导出 Git 变更文件,同时生成安全分类清单。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
PROTECTED = (
|
||||
".env", ".env.*", "application*.yml", "application*.yaml", "application*.properties",
|
||||
"bootstrap*.yml", "bootstrap*.yaml", "config/local/*", "config/dev/*",
|
||||
"*.local.*", "*.secret.*", ".craftkit/local/*", ".craftkit/cache/*",
|
||||
)
|
||||
BLOCKED_SUFFIXES = {".key", ".pem", ".p12", ".pfx"}
|
||||
SECRET_PATTERN = re.compile(
|
||||
rb"(?i)(api[_-]?key|password|access[_-]?token)\s*[:=]\s*['\"]?[A-Za-z0-9_./+@-]{8,}"
|
||||
)
|
||||
|
||||
|
||||
def git(repo: Path, *args: str, check: bool = True) -> bytes:
|
||||
"""执行只读 Git 子命令,保留原始字节以支持任意合法文件名。"""
|
||||
|
||||
result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, check=False)
|
||||
if check and result.returncode:
|
||||
raise ValueError(result.stderr.decode("utf-8", errors="replace").strip())
|
||||
return result.stdout
|
||||
|
||||
|
||||
def paths_from_nul(data: bytes) -> list[str]:
|
||||
"""将 Git 的 NUL 分隔路径转换为去重后的正斜杠相对路径。"""
|
||||
|
||||
values = {item.decode("utf-8", errors="surrogateescape").replace("\\", "/") for item in data.split(b"\0") if item}
|
||||
return sorted(values)
|
||||
|
||||
|
||||
def selected_paths(args: argparse.Namespace, repo: Path) -> list[str]:
|
||||
"""根据模式选择路径;时间只负责选路径,不决定内容快照。"""
|
||||
|
||||
if args.mode == "worktree":
|
||||
values = paths_from_nul(git(repo, "diff", "--name-only", "-z", "HEAD", "--"))
|
||||
if args.include_untracked:
|
||||
values = sorted(set(values) | set(paths_from_nul(git(repo, "ls-files", "--others", "--exclude-standard", "-z"))))
|
||||
return values
|
||||
if args.mode == "range":
|
||||
if not args.base or not args.head:
|
||||
raise ValueError("range 模式必须提供 --base 和 --head")
|
||||
return paths_from_nul(git(repo, "diff", "--name-only", "-z", args.base, args.head, "--"))
|
||||
if not args.since:
|
||||
raise ValueError("time 模式必须提供 --since")
|
||||
command = ["log", f"--since={args.since}"]
|
||||
if args.until:
|
||||
command.append(f"--until={args.until}")
|
||||
command.extend(["--name-only", "--format=", "-z", "--"])
|
||||
return paths_from_nul(git(repo, *command))
|
||||
|
||||
|
||||
def safe_relative(value: str) -> PurePosixPath:
|
||||
"""拒绝绝对路径、空路径和目录逃逸。"""
|
||||
|
||||
path = PurePosixPath(value)
|
||||
if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):
|
||||
raise ValueError(f"不安全的 Git 路径:{value}")
|
||||
return path
|
||||
|
||||
|
||||
def is_protected(value: str) -> bool:
|
||||
"""识别默认不应进入交付快照的环境和本地配置。"""
|
||||
|
||||
lowered = value.casefold()
|
||||
name = PurePosixPath(lowered).name
|
||||
if name in {".env.example", ".env.sample", ".env.template"}:
|
||||
return False
|
||||
return any(fnmatch.fnmatch(lowered, pattern) or fnmatch.fnmatch(name, pattern) for pattern in PROTECTED)
|
||||
|
||||
|
||||
def evidence_allows(value: str, evidence: str) -> bool:
|
||||
"""规范必须明确写出路径、文件名或可匹配该路径的 glob。"""
|
||||
|
||||
lowered = value.casefold()
|
||||
name = PurePosixPath(lowered).name
|
||||
content = evidence.casefold()
|
||||
if lowered in content or name in content:
|
||||
return True
|
||||
tokens = re.findall(r"[a-z0-9_.*/?-]+", content)
|
||||
return any("*" in token and (fnmatch.fnmatch(lowered, token) or fnmatch.fnmatch(name, token)) for token in tokens)
|
||||
|
||||
|
||||
def read_snapshot(repo: Path, value: str, snapshot: str) -> bytes | None:
|
||||
"""从工作区或指定 Git 树读取文件;不存在时返回空值。"""
|
||||
|
||||
if snapshot == "worktree":
|
||||
path = (repo / Path(*PurePosixPath(value).parts)).resolve()
|
||||
try:
|
||||
path.relative_to(repo)
|
||||
except ValueError as error:
|
||||
raise ValueError(f"来源路径逃逸仓库:{value}") from error
|
||||
return path.read_bytes() if path.is_file() else None
|
||||
result = subprocess.run(["git", "-C", str(repo), "show", f"{snapshot}:{value}"], capture_output=True, check=False)
|
||||
return result.stdout if result.returncode == 0 else None
|
||||
|
||||
|
||||
def policy_text(args: argparse.Namespace, repo: Path) -> tuple[str, str | None]:
|
||||
"""验证规范证据位于允许的项目文档范围内。"""
|
||||
|
||||
if not args.allow_protected:
|
||||
return "", None
|
||||
if not args.policy_evidence:
|
||||
raise ValueError("允许环境配置时必须提供 --policy-evidence")
|
||||
path = (repo / args.policy_evidence).resolve()
|
||||
try:
|
||||
relative = path.relative_to(repo).as_posix()
|
||||
except ValueError as error:
|
||||
raise ValueError("规范证据必须位于项目仓库内") from error
|
||||
allowed = relative == "AGENTS.md" or relative.endswith("/AGENTS.md") or relative.startswith((".craftkit/agents/", ".craftkit/standards/"))
|
||||
if not allowed or not path.is_file():
|
||||
raise ValueError("规范证据必须是适用的 AGENTS.md 或 .craftkit Agent/规范文档")
|
||||
return path.read_text(encoding="utf-8-sig"), relative
|
||||
|
||||
|
||||
def build_manifest(args: argparse.Namespace, repo: Path, target: Path) -> tuple[dict, dict[str, bytes]]:
|
||||
"""读取候选内容并分类,blocked 项始终不能被覆盖授权解除。"""
|
||||
|
||||
evidence, evidence_path = policy_text(args, repo)
|
||||
manifest = {"mode": args.mode, "snapshot": args.snapshot, "policyEvidence": evidence_path,
|
||||
"exported": [], "protected": [], "blocked": [], "deleted": [], "missing": []}
|
||||
payloads: dict[str, bytes] = {}
|
||||
target_prefix = None
|
||||
try:
|
||||
target_prefix = target.relative_to(repo).as_posix().rstrip("/") + "/"
|
||||
except ValueError:
|
||||
pass
|
||||
for raw in selected_paths(args, repo):
|
||||
value = safe_relative(raw).as_posix()
|
||||
if target_prefix and value.startswith(target_prefix):
|
||||
continue
|
||||
data = read_snapshot(repo, value, args.snapshot)
|
||||
if data is None:
|
||||
manifest["deleted"].append(value); continue
|
||||
if PurePosixPath(value).suffix.casefold() in BLOCKED_SUFFIXES or b"PRIVATE KEY" in data or SECRET_PATTERN.search(data):
|
||||
manifest["blocked"].append(value); continue
|
||||
if is_protected(value) and not (args.allow_protected and evidence_allows(value, evidence)):
|
||||
manifest["protected"].append(value); continue
|
||||
manifest["exported"].append(value)
|
||||
payloads[value] = data
|
||||
return manifest, payloads
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""默认输出预演 JSON;显式 --apply 后才创建交付目录。"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="预览并导出 Git 变更文件")
|
||||
parser.add_argument("--repo", type=Path, required=True); parser.add_argument("--target", type=Path, required=True)
|
||||
parser.add_argument("--mode", choices=("range", "time", "worktree"), required=True)
|
||||
parser.add_argument("--base"); parser.add_argument("--head"); parser.add_argument("--since"); parser.add_argument("--until")
|
||||
parser.add_argument("--snapshot", default="HEAD"); parser.add_argument("--include-untracked", action="store_true")
|
||||
parser.add_argument("--allow-protected", action="store_true"); parser.add_argument("--policy-evidence")
|
||||
parser.add_argument("--apply", action="store_true"); parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
repo, target = args.repo.resolve(), args.target.resolve()
|
||||
try:
|
||||
git(repo, "rev-parse", "--is-inside-work-tree")
|
||||
if target == repo:
|
||||
raise ValueError("导出目标不得等于仓库根目录")
|
||||
if args.snapshot != "worktree":
|
||||
git(repo, "rev-parse", "--verify", f"{args.snapshot}^{{commit}}")
|
||||
if args.apply and target.exists() and not args.force:
|
||||
raise ValueError(f"目标目录已存在,覆盖需明确使用 --force:{target}")
|
||||
manifest, payloads = build_manifest(args, repo, target)
|
||||
manifest["operation"] = "apply" if args.apply else "dry-run"
|
||||
if args.apply:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for value, data in payloads.items():
|
||||
output = target / Path(*PurePosixPath(value).parts)
|
||||
output.parent.mkdir(parents=True, exist_ok=True); output.write_bytes(data)
|
||||
(target / "export-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
return 2 if manifest["blocked"] else 0
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"错误:{error}", file=sys.stderr); return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: identity
|
||||
description: 查看 Git 提交用户名和邮箱的生效值、来源与作用域,并在用户确认准确命令后设置仓库级或全局提交身份。适用于配置 user.name 或 user.email;凭据、签名、代理及其他 Git 配置不应触发本 Skill。
|
||||
---
|
||||
|
||||
# Git 提交身份
|
||||
|
||||
只管理 `user.name` 和 `user.email`。所有检查默认只读,仓库级与全局写入必须分别确认。
|
||||
|
||||
## 查看当前身份
|
||||
|
||||
1. 确认当前位置是否属于 Git 仓库。
|
||||
2. 分别读取最终生效值及来源:
|
||||
|
||||
```text
|
||||
git config --show-origin --get user.name
|
||||
git config --show-origin --get user.email
|
||||
git config --local --get user.name
|
||||
git config --local --get user.email
|
||||
git config --global --get user.name
|
||||
git config --global --get user.email
|
||||
```
|
||||
|
||||
3. 未设置属于正常状态,不把空结果报告为命令失败。
|
||||
4. 不读取或展示凭据存储、访问令牌及远程认证配置。
|
||||
|
||||
## 设置确认
|
||||
|
||||
收集用户要修改的字段、准确值和作用域。作用域只能是:
|
||||
|
||||
- `local`:仅当前仓库,写入仓库 Git 配置。
|
||||
- `global`:当前用户的全部仓库,属于用户级修改。
|
||||
|
||||
执行前展示现有值、新值、作用域及完整命令。只有用户确认该准确命令后才执行:
|
||||
|
||||
```text
|
||||
git config --local user.name "<name>"
|
||||
git config --local user.email "<email>"
|
||||
git config --global user.name "<name>"
|
||||
git config --global user.email "<email>"
|
||||
```
|
||||
|
||||
一次确认只覆盖已展示字段和作用域。用户更改值或作用域后必须重新确认;不得把查看身份的请求视为设置授权。
|
||||
|
||||
## 执行后验证
|
||||
|
||||
重新读取目标作用域和最终生效值,确认写入结果。失败时保留原始错误,不自动改用另一作用域,也不修改 `commit.gpgsign`、`user.signingkey`、credential、proxy 或其他配置。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Git Identity"
|
||||
short_description: "查看并安全设置 Git 提交用户名和邮箱"
|
||||
default_prompt: "使用 $identity 检查当前 Git 提交身份,并在我确认后设置正确作用域。"
|
||||
Reference in New Issue
Block a user