feat(git): 新增提交身份与变更导出 Skill
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user