backup_manager.py 15 KB

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