瀏覽代碼

fix: backup reports real git failures and aborts tagging

lingfengQAQ 1 周之前
父節點
當前提交
ff27f0026d

+ 5 - 5
docs/superpowers/plans/2026-06-10-audit-fix-plan.md

@@ -22,9 +22,9 @@
 - Modify: `webnovel-writer/scripts/backup_manager.py:150-166, 228-254`
 - Test: `webnovel-writer/scripts/tests/test_backup_manager.py`
 
-- [ ] **Step 1: 写失败测试**:在 tmp git 仓库中故意不配置 `user.name/user.email`(`git config --local --unset` 或 `-c user.useConfigOnly=true`),调用 `backup()`,断言返回失败且输出包含"备份失败"、不产生 `ch{N}` tag。
-- [ ] **Step 2: 运行确认现状是假成功**(当前会打印 ✅ 并打 tag 在旧 HEAD)。
-- [ ] **Step 3: 修复 `_run_git_command`**:`check=False` 分支改为返回 `(result.returncode == 0, stdout, stderr)`;调用方据真实退出码判断。"nothing to commit" 改为从 stdout/stderr 文本判断(当前 `:233` 的 `if not success and "nothing to commit"` 是永假死代码,一并删除重写):
+- [x] **Step 1: 写失败测试**:在 tmp git 仓库中故意不配置 `user.name/user.email`(`git config --local --unset` 或 `-c user.useConfigOnly=true`),调用 `backup()`,断言返回失败且输出包含"备份失败"、不产生 `ch{N}` tag。
+- [x] **Step 2: 运行确认现状是假成功**(当前会打印 ✅ 并打 tag 在旧 HEAD)。
+- [x] **Step 3: 修复 `_run_git_command`**:`check=False` 分支改为返回 `(result.returncode == 0, stdout, stderr)`;调用方据真实退出码判断。"nothing to commit" 改为从 stdout/stderr 文本判断(当前 `:233` 的 `if not success and "nothing to commit"` 是永假死代码,一并删除重写):
 
 ```python
 def _run_git_command(self, args, check=True):
@@ -38,8 +38,8 @@ def _run_git_command(self, args, check=True):
     return ok, result.stdout, result.stderr
 ```
 
-- [ ] **Step 4: `backup()` 中 commit 失败时中止**:不打 tag、返回非零、输出含修复指引(提示运行 `git config user.name/user.email`);"nothing to commit" 视为成功但提示"本章无变更"。
-- [ ] **Step 5: 跑全部 backup 测试通过后提交** `fix: backup reports real git failures and aborts tagging`。
+- [x] **Step 4: `backup()` 中 commit 失败时中止**:不打 tag、返回非零、输出含修复指引(提示运行 `git config user.name/user.email`);"nothing to commit" 视为成功但提示"本章无变更"。
+- [x] **Step 5: 跑全部 backup 测试通过后提交** `fix: backup reports real git failures and aborts tagging`。
 
 ### Task 2: rollback 改为前滚式恢复,去掉 detached HEAD 与硬编码 master
 

+ 64 - 40
webnovel-writer/scripts/backup_manager.py

@@ -67,6 +67,11 @@ from project_locator import resolve_project_root
 if sys.platform == "win32":
     enable_windows_utf8_stdio()
 
+
+class BackupError(RuntimeError):
+    """Git backup operation failed."""
+
+
 class GitBackupManager:
     """基于 Git 的备份管理器(支持优雅降级)"""
 
@@ -147,30 +152,38 @@ __pycache__/
             print(f"❌ Git 初始化失败: {e}")
             return False
 
-    def _run_git_command(self, args: List[str], check: bool = True) -> Tuple[bool, str]:
+    def _run_git_command(self, args: List[str], check: bool = True) -> Tuple[bool, str, str]:
         """执行 Git 命令(支持优雅降级)"""
         if not self.git_available:
-            return False, "Git 不可用"
+            return False, "", "Git 不可用"
 
         try:
             result = subprocess.run(
-                ["git"] + args,
+                ["git", *args],
                 cwd=self.project_root,
-                check=check,
                 capture_output=True,
                 text=True,
-                encoding='utf-8',
+                encoding="utf-8",
                 timeout=60
             )
-
-            return True, result.stdout
-
-        except subprocess.CalledProcessError as e:
-            return False, e.stderr
+            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:
-            return False, "Git 命令超时"
+            if check:
+                raise BackupError(f"git {' '.join(args)} 失败: Git 命令超时")
+            return False, "", "Git 命令超时"
         except OSError as e:
-            return False, str(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 不可用时的降级方案)"""
@@ -209,9 +222,9 @@ __pycache__/
             return self._local_backup(chapter_num)
 
         # Step 1: git add .
-        success, output = self._run_git_command(["add", "."])
+        success, stdout, stderr = self._run_git_command(["add", "."], check=False)
         if not success:
-            print(f"❌ git add 失败: {output}")
+            print(f"❌ 备份失败:git add 失败: {self._format_git_output(stdout, stderr)}")
             return False
 
         # Step 2: git commit
@@ -225,16 +238,20 @@ __pycache__/
             safe_chapter_title = sanitize_commit_message(chapter_title)
             commit_message += f": {safe_chapter_title}"
 
-        success, output = self._run_git_command(
+        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 output:
-            print("⚠️  无变更,跳过提交")
+        if not success and "nothing to commit" in commit_output.lower():
+            print("⚠️  本章无变更,跳过提交")
             return True
         elif not success:
-            print(f"❌ git commit 失败: {output}")
+            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}")
@@ -245,9 +262,9 @@ __pycache__/
         # 删除旧 tag(如果存在)
         self._run_git_command(["tag", "-d", tag_name], check=False)
 
-        success, output = self._run_git_command(["tag", tag_name])
+        success, stdout, stderr = self._run_git_command(["tag", tag_name], check=False)
         if not success:
-            print(f"⚠️  创建 tag 失败(非致命): {output}")
+            print(f"⚠️  创建 tag 失败(非致命): {self._format_git_output(stdout, stderr)}")
         else:
             print(f"✅ Git tag 已创建: {tag_name}")
 
@@ -266,7 +283,10 @@ __pycache__/
         print(f"⚠️  警告:这将丢弃所有未提交的变更!")
 
         # 检查是否有未提交的变更
-        success, status_output = self._run_git_command(["status", "--porcelain"])
+        success, status_output, status_error = self._run_git_command(["status", "--porcelain"], check=False)
+        if not success:
+            print(f"❌ 读取 Git 状态失败: {self._format_git_output(status_output, status_error)}")
+            return False
 
         if status_output.strip():
             print("\n⚠️  检测到未提交的变更:")
@@ -278,26 +298,27 @@ __pycache__/
 
             print(f"\n💾 正在创建备份分支: {backup_branch}")
 
-            success, _ = self._run_git_command(["checkout", "-b", backup_branch])
+            success, _, _ = self._run_git_command(["checkout", "-b", backup_branch], check=False)
             if not success:
                 print("❌ 创建备份分支失败")
                 return False
 
-            success, _ = self._run_git_command(["add", "."])
-            success, _ = self._run_git_command(
-                ["commit", "-m", f"Backup before rollback to chapter {chapter_num}"]
+            success, _, _ = self._run_git_command(["add", "."], check=False)
+            success, _, _ = self._run_git_command(
+                ["commit", "-m", f"Backup before rollback to chapter {chapter_num}"],
+                check=False,
             )
 
             print(f"✅ 备份分支已创建: {backup_branch}")
 
             # 切换回 master
-            success, _ = self._run_git_command(["checkout", "master"])
+            success, _, _ = self._run_git_command(["checkout", "master"], check=False)
 
         # 执行回滚
-        success, output = self._run_git_command(["checkout", tag_name])
+        success, stdout, stderr = self._run_git_command(["checkout", tag_name], check=False)
 
         if not success:
-            print(f"❌ 回滚失败: {output}")
+            print(f"❌ 回滚失败: {self._format_git_output(stdout, stderr)}")
             print(f"💡 提示:确保 tag '{tag_name}' 存在(运行 --list 查看所有备份)")
             return False
 
@@ -316,10 +337,10 @@ __pycache__/
 
         print(f"📊 对比第 {chapter_a} 章 与 第 {chapter_b} 章的差异...\n")
 
-        success, output = self._run_git_command(["diff", tag_a, tag_b, "--stat"])
+        success, output, error = self._run_git_command(["diff", tag_a, tag_b, "--stat"], check=False)
 
         if not success:
-            print(f"❌ 对比失败: {output}")
+            print(f"❌ 对比失败: {self._format_git_output(output, error)}")
             return
 
         print("📈 文件变更统计:")
@@ -327,8 +348,9 @@ __pycache__/
 
         # 显示 state.json 的详细差异
         print("\n📝 state.json 详细差异:")
-        success, state_diff = self._run_git_command(
-            ["diff", tag_a, tag_b, "--", ".webnovel/state.json"]
+        success, state_diff, _ = self._run_git_command(
+            ["diff", tag_a, tag_b, "--", ".webnovel/state.json"],
+            check=False,
         )
 
         if success and state_diff:
@@ -344,7 +366,7 @@ __pycache__/
         print("\n📚 备份列表(Git tags):\n")
 
         # 获取所有 tags
-        success, tags_output = self._run_git_command(["tag", "-l", "ch*"])
+        success, tags_output, _ = self._run_git_command(["tag", "-l", "ch*"], check=False)
 
         if not success or not tags_output:
             print("⚠️  暂无备份")
@@ -357,8 +379,9 @@ __pycache__/
             chapter_num = int(tag[2:])
 
             # 获取该 tag 的提交信息
-            success, commit_info = self._run_git_command(
-                ["log", tag, "-1", "--format=%h %ci %s"]
+            success, commit_info, _ = self._run_git_command(
+                ["log", tag, "-1", "--format=%h %ci %s"],
+                check=False,
             )
 
             if success:
@@ -368,8 +391,9 @@ __pycache__/
 
         # 显示最近 5 次提交
         print("\n📜 最近提交历史:\n")
-        success, log_output = self._run_git_command(
-            ["log", "--oneline", "-5"]
+        success, log_output, _ = self._run_git_command(
+            ["log", "--oneline", "-5"],
+            check=False,
         )
 
         if success:
@@ -383,17 +407,17 @@ __pycache__/
         print(f"🌿 从第 {chapter_num} 章创建分支: {branch_name}")
 
         # 检查 tag 是否存在
-        success, _ = self._run_git_command(["rev-parse", tag_name], check=False)
+        success, _, _ = self._run_git_command(["rev-parse", tag_name], check=False)
 
         if not success:
             print(f"❌ Tag '{tag_name}' 不存在")
             return False
 
         # 创建分支
-        success, output = self._run_git_command(["branch", branch_name, tag_name])
+        success, output, error = self._run_git_command(["branch", branch_name, tag_name], check=False)
 
         if not success:
-            print(f"❌ 创建分支失败: {output}")
+            print(f"❌ 创建分支失败: {self._format_git_output(output, error)}")
             return False
 
         print(f"✅ 分支已创建: {branch_name}")

+ 39 - 0
webnovel-writer/scripts/tests/test_backup_manager.py

@@ -21,3 +21,42 @@ def test_backup_manager_gitignore_excludes_env(tmp_path, monkeypatch):
     assert ".env" in gitignore
     assert ".env.*" in gitignore
     assert "!.env.example" in gitignore
+
+
+def _run_git(project_root, *args):
+    return subprocess.run(
+        ["git", *args],
+        cwd=project_root,
+        capture_output=True,
+        text=True,
+        encoding="utf-8",
+        check=False,
+    )
+
+
+def test_backup_aborts_when_git_commit_fails_without_identity(tmp_path, monkeypatch, capsys):
+    isolated_home = tmp_path / "home"
+    isolated_home.mkdir()
+    project_root = tmp_path / "project"
+    project_root.mkdir()
+
+    monkeypatch.setenv("HOME", str(isolated_home))
+    monkeypatch.setenv("USERPROFILE", str(isolated_home))
+    monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
+
+    assert _run_git(project_root, "init", "-b", "main").returncode == 0
+    assert _run_git(project_root, "config", "--local", "user.useConfigOnly", "true").returncode == 0
+    _run_git(project_root, "config", "--local", "--unset", "user.name")
+    _run_git(project_root, "config", "--local", "--unset", "user.email")
+
+    manuscript_dir = project_root / "正文"
+    manuscript_dir.mkdir()
+    (manuscript_dir / "第0001章-test.md").write_text("正文", encoding="utf-8")
+
+    manager = GitBackupManager(str(project_root))
+
+    assert manager.backup(1, "身份缺失") is False
+
+    output = capsys.readouterr().out
+    assert "备份失败" in output
+    assert _run_git(project_root, "rev-parse", "--verify", "ch0001").returncode != 0