backup_manager.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. #!/usr/bin/env python3
  2. """
  3. Git 集成备份管理系统 (Backup Manager with Git)
  4. 核心理念:写 200万字必然会"写废设定",需要支持任意时间点回滚。
  5. 🔧 重大升级:使用 Git 进行原子性版本控制
  6. 为什么选择 Git:
  7. 1. ✅ 原子性回滚:state.json + 正文/*.md 同时回滚,数据 100% 一致
  8. 2. ✅ 增量存储:只存储 diff,节省 95% 空间
  9. 3. ✅ 成熟稳定:经过 20 年验证的版本控制系统
  10. 4. ✅ 分支管理:天然支持"平行世界"创作
  11. 功能:
  12. 1. 自动 Git 提交:每次 /webnovel-write 完成后自动 commit
  13. 2. 原子性回滚:git checkout 同时回滚所有文件
  14. 3. 版本历史:git log 查看完整历史
  15. 4. 差异对比:git diff 查看任意两个版本的差异
  16. 5. 分支创建:git branch 从任意时间点创建分支
  17. 使用方式:
  18. # 在第 45 章完成后自动备份(自动 git commit)
  19. python backup_manager.py --chapter 45
  20. # 回滚到第 30 章状态(git checkout)
  21. python backup_manager.py --rollback 30
  22. # 查看第 20 章和第 40 章的差异(git diff)
  23. python backup_manager.py --diff 20 40
  24. # 从第 50 章创建分支(git branch)
  25. python backup_manager.py --create-branch 50 --branch-name "alternative-ending"
  26. # 列出所有备份(git log)
  27. python backup_manager.py --list
  28. Git 提交规范:
  29. - 提交信息格式: "Chapter {N}: {章节标题}"
  30. - Tag 格式: "ch{N}" (如 ch0045)
  31. - 每个章节对应一个 commit + 一个 tag
  32. 数据一致性保证:
  33. ✅ 回滚时,state.json 和所有 .md 文件同步回滚
  34. ✅ 不会出现"状态记录筑基期,但文件里写着金丹期"的数据撕裂
  35. ✅ 原子性操作,要么全部成功,要么全部失败
  36. """
  37. import subprocess
  38. import json
  39. import os
  40. import sys
  41. import shutil
  42. from pathlib import Path
  43. from runtime_compat import enable_windows_utf8_stdio
  44. from datetime import datetime
  45. from typing import Optional, List, Tuple
  46. # ============================================================================
  47. # 安全修复:导入安全工具函数(P1 MEDIUM)
  48. # ============================================================================
  49. from security_utils import sanitize_commit_message, is_git_available, is_git_repo, git_graceful_operation
  50. from project_locator import resolve_project_root
  51. # Windows 编码兼容性修复
  52. if sys.platform == "win32":
  53. enable_windows_utf8_stdio()
  54. class BackupError(RuntimeError):
  55. """Git backup operation failed."""
  56. class GitBackupManager:
  57. """基于 Git 的备份管理器(支持优雅降级)"""
  58. def __init__(self, project_root: str):
  59. self.project_root = Path(project_root)
  60. self.git_dir = self.project_root / ".git"
  61. self.git_available = is_git_available()
  62. if not self.git_available:
  63. print("⚠️ Git 不可用,将使用本地备份模式")
  64. print("💡 如需启用 Git 版本控制,请安装 Git: https://git-scm.com/")
  65. return
  66. # 检查 Git 是否初始化
  67. if not self.git_dir.exists():
  68. print("⚠️ Git 未初始化,请先运行 /webnovel-init 或手动执行 git init")
  69. print("💡 现在自动初始化 Git...")
  70. self._init_git()
  71. def _init_git(self) -> bool:
  72. """初始化 Git 仓库"""
  73. try:
  74. # git init
  75. subprocess.run(
  76. ["git", "init"],
  77. cwd=self.project_root,
  78. check=True,
  79. capture_output=True
  80. )
  81. # 创建 .gitignore
  82. gitignore_file = self.project_root / ".gitignore"
  83. if not gitignore_file.exists():
  84. with open(gitignore_file, 'w', encoding='utf-8') as f:
  85. f.write("""# Python
  86. __pycache__/
  87. *.py[cod]
  88. *.so
  89. # Temporary files
  90. *.tmp
  91. *.bak
  92. .DS_Store
  93. # IDE
  94. .vscode/
  95. .idea/
  96. # Don't ignore .webnovel (we need to track state.json)
  97. # But ignore cache files
  98. .webnovel/context_cache.json
  99. # Env files
  100. .env
  101. .env.*
  102. !.env.example
  103. """)
  104. # 初始提交
  105. subprocess.run(
  106. ["git", "add", "."],
  107. cwd=self.project_root,
  108. check=True,
  109. capture_output=True
  110. )
  111. subprocess.run(
  112. ["git", "commit", "-m", "Initial commit: Project initialized"],
  113. cwd=self.project_root,
  114. check=True,
  115. capture_output=True
  116. )
  117. print("✅ Git 仓库已初始化")
  118. return True
  119. except subprocess.CalledProcessError as e:
  120. print(f"❌ Git 初始化失败: {e}")
  121. return False
  122. def _run_git_command(self, args: List[str], check: bool = True) -> Tuple[bool, str, str]:
  123. """执行 Git 命令(支持优雅降级)"""
  124. if not self.git_available:
  125. return False, "", "Git 不可用"
  126. try:
  127. result = subprocess.run(
  128. ["git", *args],
  129. cwd=self.project_root,
  130. capture_output=True,
  131. text=True,
  132. encoding="utf-8",
  133. timeout=60
  134. )
  135. ok = result.returncode == 0
  136. if check and not ok:
  137. message = (result.stderr or result.stdout).strip()
  138. raise BackupError(f"git {' '.join(args)} 失败: {message}")
  139. return ok, result.stdout, result.stderr
  140. except subprocess.TimeoutExpired:
  141. if check:
  142. raise BackupError(f"git {' '.join(args)} 失败: Git 命令超时")
  143. return False, "", "Git 命令超时"
  144. except OSError as e:
  145. if check:
  146. raise BackupError(f"git {' '.join(args)} 失败: {e}")
  147. return False, "", str(e)
  148. @staticmethod
  149. def _format_git_output(stdout: str, stderr: str) -> str:
  150. """合并 Git 输出,优先保留 stderr 中的故障信息。"""
  151. return "\n".join(part.strip() for part in (stderr, stdout) if part.strip())
  152. def _local_backup(self, chapter_num: int) -> bool:
  153. """本地备份(Git 不可用时的降级方案)"""
  154. backup_dir = self.project_root / ".webnovel" / "backups"
  155. backup_dir.mkdir(parents=True, exist_ok=True)
  156. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
  157. backup_name = f"snapshot_ch{chapter_num:04d}_{timestamp}"
  158. backup_path = backup_dir / backup_name
  159. try:
  160. backup_path.mkdir(parents=True, exist_ok=True)
  161. copied = []
  162. for folder_name in ("正文", "大纲", "设定集"):
  163. source_dir = self.project_root / folder_name
  164. if source_dir.exists():
  165. shutil.copytree(source_dir, backup_path / folder_name)
  166. copied.append(folder_name)
  167. state_file = self.project_root / ".webnovel" / "state.json"
  168. if state_file.exists():
  169. target_state_dir = backup_path / ".webnovel"
  170. target_state_dir.mkdir(parents=True, exist_ok=True)
  171. shutil.copy2(state_file, target_state_dir / "state.json")
  172. copied.append(".webnovel/state.json")
  173. snapshots = sorted(
  174. (path for path in backup_dir.glob("snapshot_ch*") if path.is_dir()),
  175. key=lambda path: path.name,
  176. )
  177. for old_snapshot in snapshots[:-10]:
  178. shutil.rmtree(old_snapshot)
  179. print(f"✅ 本地备份完成: {backup_path}")
  180. if copied:
  181. print(f"📦 已备份: {', '.join(copied)}")
  182. else:
  183. print("⚠️ 未找到正文/大纲/设定集或 state.json 可备份")
  184. return True
  185. except OSError as e:
  186. print(f"❌ 本地备份失败: {e}")
  187. return False
  188. def backup(self, chapter_num: int, chapter_title: str = "") -> bool:
  189. """
  190. 备份当前状态(Git commit + tag,或本地备份)
  191. Args:
  192. chapter_num: 章节号
  193. chapter_title: 章节标题(可选)
  194. """
  195. print(f"📝 正在备份第 {chapter_num} 章...")
  196. # 如果 Git 不可用,使用本地备份
  197. if not self.git_available:
  198. return self._local_backup(chapter_num)
  199. # Step 1: git add .
  200. success, stdout, stderr = self._run_git_command(["add", "."], check=False)
  201. if not success:
  202. print(f"❌ 备份失败:git add 失败: {self._format_git_output(stdout, stderr)}")
  203. return False
  204. # Step 2: git commit
  205. commit_message = f"Chapter {chapter_num}"
  206. if chapter_title:
  207. # ============================================================================
  208. # 安全修复:清理提交消息,防止命令注入 (CWE-77) - P1 MEDIUM
  209. # 原代码: commit_message += f": {chapter_title}"
  210. # 漏洞: chapter_title可能包含 Git 标志(如 --author, --amend)导致命令注入
  211. # ============================================================================
  212. safe_chapter_title = sanitize_commit_message(chapter_title)
  213. commit_message += f": {safe_chapter_title}"
  214. success, stdout, stderr = self._run_git_command(
  215. ["commit", "-m", commit_message],
  216. check=False # 允许"无变更"的情况
  217. )
  218. commit_output = self._format_git_output(stdout, stderr)
  219. if not success and "nothing to commit" in commit_output.lower():
  220. print("⚠️ 本章无变更,跳过提交")
  221. return True
  222. elif not success:
  223. print(f"❌ 备份失败:git commit 失败")
  224. if commit_output:
  225. print(commit_output)
  226. print("💡 请先运行: git config user.name \"你的名字\" && git config user.email \"you@example.com\"")
  227. return False
  228. print(f"✅ Git 提交完成: {commit_message}")
  229. # Step 3: git tag
  230. tag_name = f"ch{chapter_num:04d}"
  231. # 删除旧 tag(如果存在)
  232. self._run_git_command(["tag", "-d", tag_name], check=False)
  233. success, stdout, stderr = self._run_git_command(["tag", tag_name], check=False)
  234. if not success:
  235. print(f"⚠️ 创建 tag 失败(非致命): {self._format_git_output(stdout, stderr)}")
  236. else:
  237. print(f"✅ Git tag 已创建: {tag_name}")
  238. return True
  239. def rollback(self, chapter_num: int) -> bool:
  240. """
  241. 前滚式恢复到指定章节(在当前分支创建恢复提交)
  242. """
  243. tag_name = f"ch{chapter_num:04d}"
  244. print(f"🔄 正在回滚到第 {chapter_num} 章...")
  245. print("💾 将在当前分支创建一个恢复提交,历史不会丢失")
  246. success, _, error = self._run_git_command(["rev-parse", "--verify", tag_name], check=False)
  247. if not success:
  248. print(f"❌ 备份点 {tag_name} 不存在")
  249. return False
  250. success, branch, branch_error = self._run_git_command(["symbolic-ref", "--short", "HEAD"], check=False)
  251. if not success or not branch.strip():
  252. print(f"❌ 当前不在分支上,无法创建前滚恢复提交: {self._format_git_output(branch, branch_error)}")
  253. return False
  254. success, stdout, stderr = self._run_git_command(["checkout", tag_name, "--", "."], check=False)
  255. if not success:
  256. print(f"❌ 回滚失败: {self._format_git_output(stdout, stderr)}")
  257. print(f"💡 提示:确保 tag '{tag_name}' 存在(运行 --list 查看所有备份)")
  258. return False
  259. success, stdout, stderr = self._run_git_command(["add", "-A"], check=False)
  260. if not success:
  261. print(f"❌ 回滚失败: {self._format_git_output(stdout, stderr)}")
  262. return False
  263. success, stdout, stderr = self._run_git_command(
  264. ["commit", "-m", f"rollback: 恢复到 {tag_name} 备份点"],
  265. check=False,
  266. )
  267. commit_output = self._format_git_output(stdout, stderr)
  268. if not success and "nothing to commit" not in commit_output.lower():
  269. print(f"❌ 回滚提交失败: {commit_output}")
  270. return False
  271. print(f"✅ 已在 {branch.strip()} 分支恢复到第 {chapter_num} 章!")
  272. print(f"\n💡 提示:")
  273. print(f" - 所有文件(state.json + 正文/*.md)已同步恢复")
  274. print(f" - 历史提交保留,可用 git log 查看恢复记录")
  275. return True
  276. def diff(self, chapter_a: int, chapter_b: int):
  277. """对比两个版本的差异(Git diff)"""
  278. tag_a = f"ch{chapter_a:04d}"
  279. tag_b = f"ch{chapter_b:04d}"
  280. print(f"📊 对比第 {chapter_a} 章 与 第 {chapter_b} 章的差异...\n")
  281. success, output, error = self._run_git_command(["diff", tag_a, tag_b, "--stat"], check=False)
  282. if not success:
  283. print(f"❌ 对比失败: {self._format_git_output(output, error)}")
  284. return
  285. print("📈 文件变更统计:")
  286. print(output)
  287. # 显示 state.json 的详细差异
  288. print("\n📝 state.json 详细差异:")
  289. success, state_diff, _ = self._run_git_command(
  290. ["diff", tag_a, tag_b, "--", ".webnovel/state.json"],
  291. check=False,
  292. )
  293. if success and state_diff:
  294. print(state_diff[:2000]) # 限制输出长度
  295. if len(state_diff) > 2000:
  296. print("\n...(输出过长,已截断)")
  297. else:
  298. print("(无变更)")
  299. def list_backups(self):
  300. """列出所有备份(Git log + tags)"""
  301. print("\n📚 备份列表(Git tags):\n")
  302. # 获取所有 tags
  303. success, tags_output, _ = self._run_git_command(["tag", "-l", "ch*"], check=False)
  304. if not success or not tags_output:
  305. print("⚠️ 暂无备份")
  306. return
  307. tags = sorted(tags_output.strip().split('\n'))
  308. for tag in tags:
  309. # 提取章节号
  310. chapter_num = int(tag[2:])
  311. # 获取该 tag 的提交信息
  312. success, commit_info, _ = self._run_git_command(
  313. ["log", tag, "-1", "--format=%h %ci %s"],
  314. check=False,
  315. )
  316. if success:
  317. print(f"📖 {tag} | {commit_info.strip()}")
  318. print(f"\n总计:{len(tags)} 个备份")
  319. # 显示最近 5 次提交
  320. print("\n📜 最近提交历史:\n")
  321. success, log_output, _ = self._run_git_command(
  322. ["log", "--oneline", "-5"],
  323. check=False,
  324. )
  325. if success:
  326. print(log_output)
  327. def create_branch(self, chapter_num: int, branch_name: str) -> bool:
  328. """从指定章节创建分支(Git branch)"""
  329. tag_name = f"ch{chapter_num:04d}"
  330. print(f"🌿 从第 {chapter_num} 章创建分支: {branch_name}")
  331. # 检查 tag 是否存在
  332. success, _, _ = self._run_git_command(["rev-parse", tag_name], check=False)
  333. if not success:
  334. print(f"❌ Tag '{tag_name}' 不存在")
  335. return False
  336. # 创建分支
  337. success, output, error = self._run_git_command(["branch", branch_name, tag_name], check=False)
  338. if not success:
  339. print(f"❌ 创建分支失败: {self._format_git_output(output, error)}")
  340. return False
  341. print(f"✅ 分支已创建: {branch_name}")
  342. print(f"\n💡 切换到分支:")
  343. print(f" git checkout {branch_name}")
  344. return True
  345. def main():
  346. import argparse
  347. parser = argparse.ArgumentParser(
  348. description="Git 集成备份管理系统",
  349. formatter_class=argparse.RawDescriptionHelpFormatter,
  350. epilog="""
  351. 示例:
  352. # 在第 45 章完成后自动备份
  353. python backup_manager.py --chapter 45
  354. # 回滚到第 30 章(原子性:state.json + 所有 .md 文件)
  355. python backup_manager.py --rollback 30
  356. # 查看第 20 章和第 40 章的差异
  357. python backup_manager.py --diff 20 40
  358. # 从第 50 章创建分支
  359. python backup_manager.py --create-branch 50 --branch-name "alternative-ending"
  360. # 列出所有备份
  361. python backup_manager.py --list
  362. """
  363. )
  364. parser.add_argument('--chapter', type=int, help='备份章节号')
  365. parser.add_argument('--chapter-title', help='章节标题(可选)')
  366. parser.add_argument('--rollback', type=int, metavar='CHAPTER', help='回滚到指定章节')
  367. parser.add_argument('--diff', nargs=2, type=int, metavar=('A', 'B'), help='对比两个版本')
  368. parser.add_argument('--create-branch', type=int, metavar='CHAPTER', help='从指定章节创建分支')
  369. parser.add_argument('--branch-name', help='分支名称')
  370. parser.add_argument('--list', action='store_true', help='列出所有备份')
  371. parser.add_argument('--project-root', default='.', help='项目根目录')
  372. args = parser.parse_args()
  373. # 解析项目根目录(允许传入“工作区根目录”,统一解析到真正的 book project_root)
  374. try:
  375. project_root = str(resolve_project_root(args.project_root))
  376. except FileNotFoundError as exc:
  377. print(f"❌ 无法定位项目根目录(需要包含 .webnovel/state.json): {exc}", file=sys.stderr)
  378. sys.exit(1)
  379. # 创建管理器
  380. manager = GitBackupManager(project_root)
  381. # 执行操作
  382. if args.chapter:
  383. manager.backup(args.chapter, args.chapter_title or "")
  384. elif args.rollback:
  385. manager.rollback(args.rollback)
  386. elif args.diff:
  387. manager.diff(args.diff[0], args.diff[1])
  388. elif args.create_branch:
  389. if not args.branch_name:
  390. print("❌ 创建分支需要 --branch-name 参数")
  391. sys.exit(1)
  392. manager.create_branch(args.create_branch, args.branch_name)
  393. elif args.list:
  394. manager.list_backups()
  395. else:
  396. parser.print_help()
  397. if __name__ == "__main__":
  398. main()