| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508 |
- #!/usr/bin/env python3
- """
- Git 集成备份管理系统 (Backup Manager with Git)
- 核心理念:写 200万字必然会"写废设定",需要支持任意时间点回滚。
- 🔧 重大升级:使用 Git 进行原子性版本控制
- 为什么选择 Git:
- 1. ✅ 原子性回滚:state.json + 正文/*.md 同时回滚,数据 100% 一致
- 2. ✅ 增量存储:只存储 diff,节省 95% 空间
- 3. ✅ 成熟稳定:经过 20 年验证的版本控制系统
- 4. ✅ 分支管理:天然支持"平行世界"创作
- 功能:
- 1. 自动 Git 提交:每次 /webnovel-write 完成后自动 commit
- 2. 原子性回滚:git checkout 同时回滚所有文件
- 3. 版本历史:git log 查看完整历史
- 4. 差异对比:git diff 查看任意两个版本的差异
- 5. 分支创建:git branch 从任意时间点创建分支
- 使用方式:
- # 在第 45 章完成后自动备份(自动 git commit)
- python backup_manager.py --chapter 45
- # 回滚到第 30 章状态(git checkout)
- python backup_manager.py --rollback 30
- # 查看第 20 章和第 40 章的差异(git diff)
- python backup_manager.py --diff 20 40
- # 从第 50 章创建分支(git branch)
- python backup_manager.py --create-branch 50 --branch-name "alternative-ending"
- # 列出所有备份(git log)
- python backup_manager.py --list
- Git 提交规范:
- - 提交信息格式: "Chapter {N}: {章节标题}"
- - Tag 格式: "ch{N}" (如 ch0045)
- - 每个章节对应一个 commit + 一个 tag
- 数据一致性保证:
- ✅ 回滚时,state.json 和所有 .md 文件同步回滚
- ✅ 不会出现"状态记录筑基期,但文件里写着金丹期"的数据撕裂
- ✅ 原子性操作,要么全部成功,要么全部失败
- """
- import subprocess
- import json
- import os
- import sys
- import shutil
- from pathlib import Path
- from runtime_compat import enable_windows_utf8_stdio
- from datetime import datetime
- from typing import Optional, List, Tuple
- # ============================================================================
- # 安全修复:导入安全工具函数(P1 MEDIUM)
- # ============================================================================
- from security_utils import sanitize_commit_message, is_git_available, is_git_repo, git_graceful_operation
- from project_locator import resolve_project_root
- # Windows 编码兼容性修复
- if sys.platform == "win32":
- enable_windows_utf8_stdio()
- class BackupError(RuntimeError):
- """Git backup operation failed."""
- class GitBackupManager:
- """基于 Git 的备份管理器(支持优雅降级)"""
- def __init__(self, project_root: str):
- self.project_root = Path(project_root)
- self.git_dir = self.project_root / ".git"
- self.git_available = is_git_available()
- if not self.git_available:
- print("⚠️ Git 不可用,将使用本地备份模式")
- print("💡 如需启用 Git 版本控制,请安装 Git: https://git-scm.com/")
- return
- # 检查 Git 是否初始化
- if not self.git_dir.exists():
- print("⚠️ Git 未初始化,请先运行 /webnovel-init 或手动执行 git init")
- print("💡 现在自动初始化 Git...")
- self._init_git()
- def _init_git(self) -> bool:
- """初始化 Git 仓库"""
- try:
- # git init
- subprocess.run(
- ["git", "init"],
- cwd=self.project_root,
- check=True,
- capture_output=True
- )
- # 创建 .gitignore
- gitignore_file = self.project_root / ".gitignore"
- if not gitignore_file.exists():
- with open(gitignore_file, 'w', encoding='utf-8') as f:
- f.write("""# Python
- __pycache__/
- *.py[cod]
- *.so
- # Temporary files
- *.tmp
- *.bak
- .DS_Store
- # IDE
- .vscode/
- .idea/
- # Don't ignore .webnovel (we need to track state.json)
- # But ignore cache files
- .webnovel/context_cache.json
- # Env files
- .env
- .env.*
- !.env.example
- """)
- # 初始提交
- subprocess.run(
- ["git", "add", "."],
- cwd=self.project_root,
- check=True,
- capture_output=True
- )
- subprocess.run(
- ["git", "commit", "-m", "Initial commit: Project initialized"],
- cwd=self.project_root,
- check=True,
- capture_output=True
- )
- print("✅ Git 仓库已初始化")
- return True
- except subprocess.CalledProcessError as e:
- print(f"❌ Git 初始化失败: {e}")
- return False
- def _run_git_command(self, args: List[str], check: bool = True) -> Tuple[bool, str, str]:
- """执行 Git 命令(支持优雅降级)"""
- if not self.git_available:
- return False, "", "Git 不可用"
- try:
- result = subprocess.run(
- ["git", *args],
- cwd=self.project_root,
- capture_output=True,
- text=True,
- encoding="utf-8",
- timeout=60
- )
- ok = result.returncode == 0
- if check and not ok:
- message = (result.stderr or result.stdout).strip()
- raise BackupError(f"git {' '.join(args)} 失败: {message}")
- return ok, result.stdout, result.stderr
- except subprocess.TimeoutExpired:
- if check:
- raise BackupError(f"git {' '.join(args)} 失败: Git 命令超时")
- return False, "", "Git 命令超时"
- except OSError as e:
- if check:
- raise BackupError(f"git {' '.join(args)} 失败: {e}")
- return False, "", str(e)
- @staticmethod
- def _format_git_output(stdout: str, stderr: str) -> str:
- """合并 Git 输出,优先保留 stderr 中的故障信息。"""
- return "\n".join(part.strip() for part in (stderr, stdout) if part.strip())
- def _local_backup(self, chapter_num: int) -> bool:
- """本地备份(Git 不可用时的降级方案)"""
- backup_dir = self.project_root / ".webnovel" / "backups"
- backup_dir.mkdir(parents=True, exist_ok=True)
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
- backup_name = f"snapshot_ch{chapter_num:04d}_{timestamp}"
- backup_path = backup_dir / backup_name
- try:
- backup_path.mkdir(parents=True, exist_ok=True)
- copied = []
- for folder_name in ("正文", "大纲", "设定集"):
- source_dir = self.project_root / folder_name
- if source_dir.exists():
- shutil.copytree(source_dir, backup_path / folder_name)
- copied.append(folder_name)
- state_file = self.project_root / ".webnovel" / "state.json"
- if state_file.exists():
- target_state_dir = backup_path / ".webnovel"
- target_state_dir.mkdir(parents=True, exist_ok=True)
- shutil.copy2(state_file, target_state_dir / "state.json")
- copied.append(".webnovel/state.json")
- snapshots = sorted(
- (path for path in backup_dir.glob("snapshot_ch*") if path.is_dir()),
- key=lambda path: path.name,
- )
- for old_snapshot in snapshots[:-10]:
- shutil.rmtree(old_snapshot)
- print(f"✅ 本地备份完成: {backup_path}")
- if copied:
- print(f"📦 已备份: {', '.join(copied)}")
- else:
- print("⚠️ 未找到正文/大纲/设定集或 state.json 可备份")
- return True
- except OSError as e:
- print(f"❌ 本地备份失败: {e}")
- return False
- def backup(self, chapter_num: int, chapter_title: str = "") -> bool:
- """
- 备份当前状态(Git commit + tag,或本地备份)
- Args:
- chapter_num: 章节号
- chapter_title: 章节标题(可选)
- """
- print(f"📝 正在备份第 {chapter_num} 章...")
- # 如果 Git 不可用,使用本地备份
- if not self.git_available:
- return self._local_backup(chapter_num)
- # Step 1: git add .
- success, stdout, stderr = self._run_git_command(["add", "."], check=False)
- if not success:
- print(f"❌ 备份失败:git add 失败: {self._format_git_output(stdout, stderr)}")
- return False
- # Step 2: git commit
- commit_message = f"Chapter {chapter_num}"
- if chapter_title:
- # ============================================================================
- # 安全修复:清理提交消息,防止命令注入 (CWE-77) - P1 MEDIUM
- # 原代码: commit_message += f": {chapter_title}"
- # 漏洞: chapter_title可能包含 Git 标志(如 --author, --amend)导致命令注入
- # ============================================================================
- safe_chapter_title = sanitize_commit_message(chapter_title)
- commit_message += f": {safe_chapter_title}"
- success, stdout, stderr = self._run_git_command(
- ["commit", "-m", commit_message],
- check=False # 允许"无变更"的情况
- )
- commit_output = self._format_git_output(stdout, stderr)
- if not success and "nothing to commit" in commit_output.lower():
- print("⚠️ 本章无变更,跳过提交")
- return True
- elif not success:
- print(f"❌ 备份失败:git commit 失败")
- if commit_output:
- print(commit_output)
- print("💡 请先运行: git config user.name \"你的名字\" && git config user.email \"you@example.com\"")
- return False
- print(f"✅ Git 提交完成: {commit_message}")
- # Step 3: git tag
- tag_name = f"ch{chapter_num:04d}"
- # 删除旧 tag(如果存在)
- self._run_git_command(["tag", "-d", tag_name], check=False)
- success, stdout, stderr = self._run_git_command(["tag", tag_name], check=False)
- if not success:
- print(f"⚠️ 创建 tag 失败(非致命): {self._format_git_output(stdout, stderr)}")
- else:
- print(f"✅ Git tag 已创建: {tag_name}")
- return True
- def rollback(self, chapter_num: int) -> bool:
- """
- 前滚式恢复到指定章节(在当前分支创建恢复提交)
- """
- tag_name = f"ch{chapter_num:04d}"
- print(f"🔄 正在回滚到第 {chapter_num} 章...")
- print("💾 将在当前分支创建一个恢复提交,历史不会丢失")
- success, _, error = self._run_git_command(["rev-parse", "--verify", tag_name], check=False)
- if not success:
- print(f"❌ 备份点 {tag_name} 不存在")
- return False
- success, branch, branch_error = self._run_git_command(["symbolic-ref", "--short", "HEAD"], check=False)
- if not success or not branch.strip():
- print(f"❌ 当前不在分支上,无法创建前滚恢复提交: {self._format_git_output(branch, branch_error)}")
- return False
- success, stdout, stderr = self._run_git_command(["checkout", tag_name, "--", "."], check=False)
- if not success:
- print(f"❌ 回滚失败: {self._format_git_output(stdout, stderr)}")
- print(f"💡 提示:确保 tag '{tag_name}' 存在(运行 --list 查看所有备份)")
- return False
- success, stdout, stderr = self._run_git_command(["add", "-A"], check=False)
- if not success:
- print(f"❌ 回滚失败: {self._format_git_output(stdout, stderr)}")
- return False
- success, stdout, stderr = self._run_git_command(
- ["commit", "-m", f"rollback: 恢复到 {tag_name} 备份点"],
- check=False,
- )
- commit_output = self._format_git_output(stdout, stderr)
- if not success and "nothing to commit" not in commit_output.lower():
- print(f"❌ 回滚提交失败: {commit_output}")
- return False
- print(f"✅ 已在 {branch.strip()} 分支恢复到第 {chapter_num} 章!")
- print(f"\n💡 提示:")
- print(f" - 所有文件(state.json + 正文/*.md)已同步恢复")
- print(f" - 历史提交保留,可用 git log 查看恢复记录")
- return True
- def diff(self, chapter_a: int, chapter_b: int):
- """对比两个版本的差异(Git diff)"""
- tag_a = f"ch{chapter_a:04d}"
- tag_b = f"ch{chapter_b:04d}"
- print(f"📊 对比第 {chapter_a} 章 与 第 {chapter_b} 章的差异...\n")
- success, output, error = self._run_git_command(["diff", tag_a, tag_b, "--stat"], check=False)
- if not success:
- print(f"❌ 对比失败: {self._format_git_output(output, error)}")
- return
- print("📈 文件变更统计:")
- print(output)
- # 显示 state.json 的详细差异
- print("\n📝 state.json 详细差异:")
- success, state_diff, _ = self._run_git_command(
- ["diff", tag_a, tag_b, "--", ".webnovel/state.json"],
- check=False,
- )
- if success and state_diff:
- print(state_diff[:2000]) # 限制输出长度
- if len(state_diff) > 2000:
- print("\n...(输出过长,已截断)")
- else:
- print("(无变更)")
- def list_backups(self):
- """列出所有备份(Git log + tags)"""
- print("\n📚 备份列表(Git tags):\n")
- # 获取所有 tags
- success, tags_output, _ = self._run_git_command(["tag", "-l", "ch*"], check=False)
- if not success or not tags_output:
- print("⚠️ 暂无备份")
- return
- tags = sorted(tags_output.strip().split('\n'))
- for tag in tags:
- # 提取章节号
- chapter_num = int(tag[2:])
- # 获取该 tag 的提交信息
- success, commit_info, _ = self._run_git_command(
- ["log", tag, "-1", "--format=%h %ci %s"],
- check=False,
- )
- if success:
- print(f"📖 {tag} | {commit_info.strip()}")
- print(f"\n总计:{len(tags)} 个备份")
- # 显示最近 5 次提交
- print("\n📜 最近提交历史:\n")
- success, log_output, _ = self._run_git_command(
- ["log", "--oneline", "-5"],
- check=False,
- )
- if success:
- print(log_output)
- def create_branch(self, chapter_num: int, branch_name: str) -> bool:
- """从指定章节创建分支(Git branch)"""
- tag_name = f"ch{chapter_num:04d}"
- print(f"🌿 从第 {chapter_num} 章创建分支: {branch_name}")
- # 检查 tag 是否存在
- success, _, _ = self._run_git_command(["rev-parse", tag_name], check=False)
- if not success:
- print(f"❌ Tag '{tag_name}' 不存在")
- return False
- # 创建分支
- success, output, error = self._run_git_command(["branch", branch_name, tag_name], check=False)
- if not success:
- print(f"❌ 创建分支失败: {self._format_git_output(output, error)}")
- return False
- print(f"✅ 分支已创建: {branch_name}")
- print(f"\n💡 切换到分支:")
- print(f" git checkout {branch_name}")
- return True
- def main():
- import argparse
- parser = argparse.ArgumentParser(
- description="Git 集成备份管理系统",
- formatter_class=argparse.RawDescriptionHelpFormatter,
- epilog="""
- 示例:
- # 在第 45 章完成后自动备份
- python backup_manager.py --chapter 45
- # 回滚到第 30 章(原子性:state.json + 所有 .md 文件)
- python backup_manager.py --rollback 30
- # 查看第 20 章和第 40 章的差异
- python backup_manager.py --diff 20 40
- # 从第 50 章创建分支
- python backup_manager.py --create-branch 50 --branch-name "alternative-ending"
- # 列出所有备份
- python backup_manager.py --list
- """
- )
- parser.add_argument('--chapter', type=int, help='备份章节号')
- parser.add_argument('--chapter-title', help='章节标题(可选)')
- parser.add_argument('--rollback', type=int, metavar='CHAPTER', help='回滚到指定章节')
- parser.add_argument('--diff', nargs=2, type=int, metavar=('A', 'B'), help='对比两个版本')
- parser.add_argument('--create-branch', type=int, metavar='CHAPTER', help='从指定章节创建分支')
- parser.add_argument('--branch-name', help='分支名称')
- parser.add_argument('--list', action='store_true', help='列出所有备份')
- parser.add_argument('--project-root', default='.', help='项目根目录')
- args = parser.parse_args()
- # 解析项目根目录(允许传入“工作区根目录”,统一解析到真正的 book project_root)
- try:
- project_root = str(resolve_project_root(args.project_root))
- except FileNotFoundError as exc:
- print(f"❌ 无法定位项目根目录(需要包含 .webnovel/state.json): {exc}", file=sys.stderr)
- sys.exit(1)
- # 创建管理器
- manager = GitBackupManager(project_root)
- # 执行操作
- if args.chapter:
- manager.backup(args.chapter, args.chapter_title or "")
- elif args.rollback:
- manager.rollback(args.rollback)
- elif args.diff:
- manager.diff(args.diff[0], args.diff[1])
- elif args.create_branch:
- if not args.branch_name:
- print("❌ 创建分支需要 --branch-name 参数")
- sys.exit(1)
- manager.create_branch(args.create_branch, args.branch_name)
- elif args.list:
- manager.list_backups()
- else:
- parser.print_help()
- if __name__ == "__main__":
- main()
|