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