backup_manager.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  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 GitBackupManager:
  55. """基于 Git 的备份管理器(支持优雅降级)"""
  56. def __init__(self, project_root: str):
  57. self.project_root = Path(project_root)
  58. self.git_dir = self.project_root / ".git"
  59. self.git_available = is_git_available()
  60. if not self.git_available:
  61. print("⚠️ Git 不可用,将使用本地备份模式")
  62. print("💡 如需启用 Git 版本控制,请安装 Git: https://git-scm.com/")
  63. return
  64. # 检查 Git 是否初始化
  65. if not self.git_dir.exists():
  66. print("⚠️ Git 未初始化,请先运行 /webnovel-init 或手动执行 git init")
  67. print("💡 现在自动初始化 Git...")
  68. self._init_git()
  69. def _init_git(self) -> bool:
  70. """初始化 Git 仓库"""
  71. try:
  72. # git init
  73. subprocess.run(
  74. ["git", "init"],
  75. cwd=self.project_root,
  76. check=True,
  77. capture_output=True
  78. )
  79. # 创建 .gitignore
  80. gitignore_file = self.project_root / ".gitignore"
  81. if not gitignore_file.exists():
  82. with open(gitignore_file, 'w', encoding='utf-8') as f:
  83. f.write("""# Python
  84. __pycache__/
  85. *.py[cod]
  86. *.so
  87. # Temporary files
  88. *.tmp
  89. *.bak
  90. .DS_Store
  91. # IDE
  92. .vscode/
  93. .idea/
  94. # Don't ignore .webnovel (we need to track state.json)
  95. # But ignore cache files
  96. .webnovel/context_cache.json
  97. """)
  98. # 初始提交
  99. subprocess.run(
  100. ["git", "add", "."],
  101. cwd=self.project_root,
  102. check=True,
  103. capture_output=True
  104. )
  105. subprocess.run(
  106. ["git", "commit", "-m", "Initial commit: Project initialized"],
  107. cwd=self.project_root,
  108. check=True,
  109. capture_output=True
  110. )
  111. print("✅ Git 仓库已初始化")
  112. return True
  113. except subprocess.CalledProcessError as e:
  114. print(f"❌ Git 初始化失败: {e}")
  115. return False
  116. def _run_git_command(self, args: List[str], check: bool = True) -> Tuple[bool, str]:
  117. """执行 Git 命令(支持优雅降级)"""
  118. if not self.git_available:
  119. return False, "Git 不可用"
  120. try:
  121. result = subprocess.run(
  122. ["git"] + args,
  123. cwd=self.project_root,
  124. check=check,
  125. capture_output=True,
  126. text=True,
  127. encoding='utf-8',
  128. timeout=60
  129. )
  130. return True, result.stdout
  131. except subprocess.CalledProcessError as e:
  132. return False, e.stderr
  133. except subprocess.TimeoutExpired:
  134. return False, "Git 命令超时"
  135. except OSError as e:
  136. return False, str(e)
  137. def _local_backup(self, chapter_num: int) -> bool:
  138. """本地备份(Git 不可用时的降级方案)"""
  139. backup_dir = self.project_root / ".webnovel" / "backups"
  140. backup_dir.mkdir(parents=True, exist_ok=True)
  141. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  142. backup_name = f"ch{chapter_num:04d}_{timestamp}"
  143. backup_path = backup_dir / backup_name
  144. try:
  145. # 备份 state.json
  146. state_file = self.project_root / ".webnovel" / "state.json"
  147. if state_file.exists():
  148. backup_path.mkdir(parents=True, exist_ok=True)
  149. shutil.copy2(state_file, backup_path / "state.json")
  150. print(f"✅ 本地备份完成: {backup_path}")
  151. return True
  152. except OSError as e:
  153. print(f"❌ 本地备份失败: {e}")
  154. return False
  155. def backup(self, chapter_num: int, chapter_title: str = "") -> bool:
  156. """
  157. 备份当前状态(Git commit + tag,或本地备份)
  158. Args:
  159. chapter_num: 章节号
  160. chapter_title: 章节标题(可选)
  161. """
  162. print(f"📝 正在备份第 {chapter_num} 章...")
  163. # 如果 Git 不可用,使用本地备份
  164. if not self.git_available:
  165. return self._local_backup(chapter_num)
  166. # Step 1: git add .
  167. success, output = self._run_git_command(["add", "."])
  168. if not success:
  169. print(f"❌ git add 失败: {output}")
  170. return False
  171. # Step 2: git commit
  172. commit_message = f"Chapter {chapter_num}"
  173. if chapter_title:
  174. # ============================================================================
  175. # 安全修复:清理提交消息,防止命令注入 (CWE-77) - P1 MEDIUM
  176. # 原代码: commit_message += f": {chapter_title}"
  177. # 漏洞: chapter_title可能包含 Git 标志(如 --author, --amend)导致命令注入
  178. # ============================================================================
  179. safe_chapter_title = sanitize_commit_message(chapter_title)
  180. commit_message += f": {safe_chapter_title}"
  181. success, output = self._run_git_command(
  182. ["commit", "-m", commit_message],
  183. check=False # 允许"无变更"的情况
  184. )
  185. if not success and "nothing to commit" in output:
  186. print("⚠️ 无变更,跳过提交")
  187. return True
  188. elif not success:
  189. print(f"❌ git commit 失败: {output}")
  190. return False
  191. print(f"✅ Git 提交完成: {commit_message}")
  192. # Step 3: git tag
  193. tag_name = f"ch{chapter_num:04d}"
  194. # 删除旧 tag(如果存在)
  195. self._run_git_command(["tag", "-d", tag_name], check=False)
  196. success, output = self._run_git_command(["tag", tag_name])
  197. if not success:
  198. print(f"⚠️ 创建 tag 失败(非致命): {output}")
  199. else:
  200. print(f"✅ Git tag 已创建: {tag_name}")
  201. return True
  202. def rollback(self, chapter_num: int) -> bool:
  203. """
  204. 回滚到指定章节(Git checkout)
  205. ⚠️ 警告:这会丢弃所有未提交的变更!
  206. """
  207. tag_name = f"ch{chapter_num:04d}"
  208. print(f"🔄 正在回滚到第 {chapter_num} 章...")
  209. print(f"⚠️ 警告:这将丢弃所有未提交的变更!")
  210. # 检查是否有未提交的变更
  211. success, status_output = self._run_git_command(["status", "--porcelain"])
  212. if status_output.strip():
  213. print("\n⚠️ 检测到未提交的变更:")
  214. print(status_output)
  215. # 创建备份提交
  216. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  217. backup_branch = f"backup_before_rollback_{timestamp}"
  218. print(f"\n💾 正在创建备份分支: {backup_branch}")
  219. success, _ = self._run_git_command(["checkout", "-b", backup_branch])
  220. if not success:
  221. print("❌ 创建备份分支失败")
  222. return False
  223. success, _ = self._run_git_command(["add", "."])
  224. success, _ = self._run_git_command(
  225. ["commit", "-m", f"Backup before rollback to chapter {chapter_num}"]
  226. )
  227. print(f"✅ 备份分支已创建: {backup_branch}")
  228. # 切换回 master
  229. success, _ = self._run_git_command(["checkout", "master"])
  230. # 执行回滚
  231. success, output = self._run_git_command(["checkout", tag_name])
  232. if not success:
  233. print(f"❌ 回滚失败: {output}")
  234. print(f"💡 提示:确保 tag '{tag_name}' 存在(运行 --list 查看所有备份)")
  235. return False
  236. print(f"✅ 已回滚到第 {chapter_num} 章!")
  237. print(f"\n💡 提示:")
  238. print(f" - 所有文件(state.json + 正文/*.md)已同步回滚")
  239. print(f" - 如需恢复,运行: git checkout master")
  240. return True
  241. def diff(self, chapter_a: int, chapter_b: int):
  242. """对比两个版本的差异(Git diff)"""
  243. tag_a = f"ch{chapter_a:04d}"
  244. tag_b = f"ch{chapter_b:04d}"
  245. print(f"📊 对比第 {chapter_a} 章 与 第 {chapter_b} 章的差异...\n")
  246. success, output = self._run_git_command(["diff", tag_a, tag_b, "--stat"])
  247. if not success:
  248. print(f"❌ 对比失败: {output}")
  249. return
  250. print("📈 文件变更统计:")
  251. print(output)
  252. # 显示 state.json 的详细差异
  253. print("\n📝 state.json 详细差异:")
  254. success, state_diff = self._run_git_command(
  255. ["diff", tag_a, tag_b, "--", ".webnovel/state.json"]
  256. )
  257. if success and state_diff:
  258. print(state_diff[:2000]) # 限制输出长度
  259. if len(state_diff) > 2000:
  260. print("\n...(输出过长,已截断)")
  261. else:
  262. print("(无变更)")
  263. def list_backups(self):
  264. """列出所有备份(Git log + tags)"""
  265. print("\n📚 备份列表(Git tags):\n")
  266. # 获取所有 tags
  267. success, tags_output = self._run_git_command(["tag", "-l", "ch*"])
  268. if not success or not tags_output:
  269. print("⚠️ 暂无备份")
  270. return
  271. tags = sorted(tags_output.strip().split('\n'))
  272. for tag in tags:
  273. # 提取章节号
  274. chapter_num = int(tag[2:])
  275. # 获取该 tag 的提交信息
  276. success, commit_info = self._run_git_command(
  277. ["log", tag, "-1", "--format=%h %ci %s"]
  278. )
  279. if success:
  280. print(f"📖 {tag} | {commit_info.strip()}")
  281. print(f"\n总计:{len(tags)} 个备份")
  282. # 显示最近 5 次提交
  283. print("\n📜 最近提交历史:\n")
  284. success, log_output = self._run_git_command(
  285. ["log", "--oneline", "-5"]
  286. )
  287. if success:
  288. print(log_output)
  289. def create_branch(self, chapter_num: int, branch_name: str) -> bool:
  290. """从指定章节创建分支(Git branch)"""
  291. tag_name = f"ch{chapter_num:04d}"
  292. print(f"🌿 从第 {chapter_num} 章创建分支: {branch_name}")
  293. # 检查 tag 是否存在
  294. success, _ = self._run_git_command(["rev-parse", tag_name], check=False)
  295. if not success:
  296. print(f"❌ Tag '{tag_name}' 不存在")
  297. return False
  298. # 创建分支
  299. success, output = self._run_git_command(["branch", branch_name, tag_name])
  300. if not success:
  301. print(f"❌ 创建分支失败: {output}")
  302. return False
  303. print(f"✅ 分支已创建: {branch_name}")
  304. print(f"\n💡 切换到分支:")
  305. print(f" git checkout {branch_name}")
  306. return True
  307. def main():
  308. import argparse
  309. parser = argparse.ArgumentParser(
  310. description="Git 集成备份管理系统",
  311. formatter_class=argparse.RawDescriptionHelpFormatter,
  312. epilog="""
  313. 示例:
  314. # 在第 45 章完成后自动备份
  315. python backup_manager.py --chapter 45
  316. # 回滚到第 30 章(原子性:state.json + 所有 .md 文件)
  317. python backup_manager.py --rollback 30
  318. # 查看第 20 章和第 40 章的差异
  319. python backup_manager.py --diff 20 40
  320. # 从第 50 章创建分支
  321. python backup_manager.py --create-branch 50 --branch-name "alternative-ending"
  322. # 列出所有备份
  323. python backup_manager.py --list
  324. """
  325. )
  326. parser.add_argument('--chapter', type=int, help='备份章节号')
  327. parser.add_argument('--chapter-title', help='章节标题(可选)')
  328. parser.add_argument('--rollback', type=int, metavar='CHAPTER', help='回滚到指定章节')
  329. parser.add_argument('--diff', nargs=2, type=int, metavar=('A', 'B'), help='对比两个版本')
  330. parser.add_argument('--create-branch', type=int, metavar='CHAPTER', help='从指定章节创建分支')
  331. parser.add_argument('--branch-name', help='分支名称')
  332. parser.add_argument('--list', action='store_true', help='列出所有备份')
  333. parser.add_argument('--project-root', default='.', help='项目根目录')
  334. args = parser.parse_args()
  335. # 解析项目根目录(支持从仓库根目录运行)
  336. project_root = args.project_root
  337. if project_root == '.' and not (Path('.') / '.webnovel' / 'state.json').exists():
  338. try:
  339. project_root = str(resolve_project_root())
  340. except FileNotFoundError:
  341. # 维持向后兼容:仍然使用用户提供的 cwd
  342. project_root = args.project_root
  343. # 创建管理器
  344. manager = GitBackupManager(project_root)
  345. # 执行操作
  346. if args.chapter:
  347. manager.backup(args.chapter, args.chapter_title or "")
  348. elif args.rollback:
  349. manager.rollback(args.rollback)
  350. elif args.diff:
  351. manager.diff(args.diff[0], args.diff[1])
  352. elif args.create_branch:
  353. if not args.branch_name:
  354. print("❌ 创建分支需要 --branch-name 参数")
  355. sys.exit(1)
  356. manager.create_branch(args.create_branch, args.branch_name)
  357. elif args.list:
  358. manager.list_backups()
  359. else:
  360. parser.print_help()
  361. if __name__ == "__main__":
  362. main()