103 lines
5.7 KiB
Python
103 lines
5.7 KiB
Python
"""验证 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()
|