"""验证 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()