diff --git a/AGENTS.md b/AGENTS.md index c65aff3..fd7b8f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ plugins//skills// - 本地交接默认位于 `.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/` 不得保存凭据、令牌、私钥、个人机器绝对路径或无必要的个人信息。 diff --git a/README.md b/README.md index f4017d7..9550deb 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/migration/MIGRATION_PLAN.md b/migration/MIGRATION_PLAN.md index e04a266..27f1721 100644 --- a/migration/MIGRATION_PLAN.md +++ b/migration/MIGRATION_PLAN.md @@ -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`。 diff --git a/migration/source-lock.json b/migration/source-lock.json index d3272bc..f306d06 100644 --- a/migration/source-lock.json +++ b/migration/source-lock.json @@ -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", diff --git a/migration/tests/test_git_export.py b/migration/tests/test_git_export.py new file mode 100644 index 0000000..7e63e8e --- /dev/null +++ b/migration/tests/test_git_export.py @@ -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() diff --git a/plugins/git/.codex-plugin/plugin.json b/plugins/git/.codex-plugin/plugin.json index 86c5d22..f88c36d 100644 --- a/plugins/git/.codex-plugin/plugin.json +++ b/plugins/git/.codex-plugin/plugin.json @@ -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"], diff --git a/plugins/git/skills/export/SKILL.md b/plugins/git/skills/export/SKILL.md new file mode 100644 index 0000000..aa450ef --- /dev/null +++ b/plugins/git/skills/export/SKILL.md @@ -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 或部署。 diff --git a/plugins/git/skills/export/agents/openai.yaml b/plugins/git/skills/export/agents/openai.yaml new file mode 100644 index 0000000..976535f --- /dev/null +++ b/plugins/git/skills/export/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Export Git Changes" + short_description: "预览并安全导出 Git 变更文件和分类清单" + default_prompt: "使用 $export 预览这批 Git 变更,排除环境配置后再导出交付快照。" diff --git a/plugins/git/skills/export/references/modes.md b/plugins/git/skills/export/references/modes.md new file mode 100644 index 0000000..1bd7575 --- /dev/null +++ b/plugins/git/skills/export/references/modes.md @@ -0,0 +1,21 @@ +# 导出模式 + +```text +python scripts/export.py --repo --target --mode range --base --head --snapshot +python scripts/export.py --repo --target --mode time --since