Sfoglia il codice sorgente

fix: rollback is forward-only, never detaches HEAD

lingfengQAQ 1 settimana fa
parent
commit
03ffc9a19d

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

@@ -47,8 +47,8 @@ def _run_git_command(self, args, check=True):
 - Modify: `webnovel-writer/scripts/backup_manager.py:294-307`
 - Test: `webnovel-writer/scripts/tests/test_backup_manager.py`
 
-- [ ] **Step 1: 写测试**:建 tmp 仓库(默认分支命名为 `main`),打两个 ch tag,回滚到 ch1 后断言:(a) 仍在原分支(`git symbolic-ref HEAD` 成功且为 main);(b) 工作区内容等于 ch1;(c) `git log` 多出一个"rollback"提交(历史不丢)。
-- [ ] **Step 2: 实现前滚式回滚**:
+- [x] **Step 1: 写测试**:建 tmp 仓库(默认分支命名为 `main`),打两个 ch tag,回滚到 ch1 后断言:(a) 仍在原分支(`git symbolic-ref HEAD` 成功且为 main);(b) 工作区内容等于 ch1;(c) `git log` 多出一个"rollback"提交(历史不丢)。
+- [x] **Step 2: 实现前滚式回滚**:
 
 ```python
 def rollback(self, chapter: int) -> bool:
@@ -66,8 +66,8 @@ def rollback(self, chapter: int) -> bool:
     return True
 ```
 
-- [ ] **Step 3: 删除所有 `checkout master` 硬编码**;任何需要分支名的地方用 `git symbolic-ref --short HEAD` 探测。
-- [ ] **Step 4: 测试通过后提交** `fix: rollback is forward-only, never detaches HEAD`。
+- [x] **Step 3: 删除所有 `checkout master` 硬编码**;任何需要分支名的地方用 `git symbolic-ref --short HEAD` 探测。
+- [x] **Step 4: 测试通过后提交** `fix: rollback is forward-only, never detaches HEAD`。
 
 ### Task 3: 无 Git 时的降级备份必须覆盖正文,或醒目声明没有
 

+ 26 - 37
webnovel-writer/scripts/backup_manager.py

@@ -272,60 +272,49 @@ __pycache__/
 
     def rollback(self, chapter_num: int) -> bool:
         """
-        回滚到指定章节(Git checkout)
-
-        ⚠️ 警告:这会丢弃所有未提交的变更!
+        前滚式恢复到指定章节(在当前分支创建恢复提交)
         """
 
         tag_name = f"ch{chapter_num:04d}"
 
         print(f"🔄 正在回滚到第 {chapter_num} 章...")
-        print(f"⚠️  警告:这将丢弃所有未提交的变更!")
+        print("💾 将在当前分支创建一个恢复提交,历史不会丢失")
 
-        # 检查是否有未提交的变更
-        success, status_output, status_error = self._run_git_command(["status", "--porcelain"], check=False)
+        success, _, error = self._run_git_command(["rev-parse", "--verify", tag_name], check=False)
         if not success:
-            print(f"❌ 读取 Git 状态失败: {self._format_git_output(status_output, status_error)}")
+            print(f"❌ 备份点 {tag_name} 不存在")
             return False
 
-        if status_output.strip():
-            print("\n⚠️  检测到未提交的变更:")
-            print(status_output)
-
-            # 创建备份提交
-            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-            backup_branch = f"backup_before_rollback_{timestamp}"
-
-            print(f"\n💾 正在创建备份分支: {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", "."], 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"], check=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)
+        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
 
-        print(f"✅ 已回滚到第 {chapter_num} 章!")
+        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 checkout master")
+        print(f"  - 所有文件(state.json + 正文/*.md)已同步恢复")
+        print(f"  - 历史提交保留,可用 git log 查看恢复记录")
 
         return True
 

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

@@ -34,6 +34,11 @@ def _run_git(project_root, *args):
     )
 
 
+def _configure_git_identity(project_root):
+    assert _run_git(project_root, "config", "user.name", "Test Author").returncode == 0
+    assert _run_git(project_root, "config", "user.email", "author@example.com").returncode == 0
+
+
 def test_backup_aborts_when_git_commit_fails_without_identity(tmp_path, monkeypatch, capsys):
     isolated_home = tmp_path / "home"
     isolated_home.mkdir()
@@ -60,3 +65,35 @@ def test_backup_aborts_when_git_commit_fails_without_identity(tmp_path, monkeypa
     output = capsys.readouterr().out
     assert "备份失败" in output
     assert _run_git(project_root, "rev-parse", "--verify", "ch0001").returncode != 0
+
+
+def test_rollback_restores_files_on_current_branch_with_new_commit(tmp_path):
+    project_root = tmp_path / "project"
+    project_root.mkdir()
+    assert _run_git(project_root, "init", "-b", "main").returncode == 0
+    _configure_git_identity(project_root)
+
+    manuscript_dir = project_root / "正文"
+    manuscript_dir.mkdir()
+    chapter_file = manuscript_dir / "第0001章-test.md"
+
+    chapter_file.write_text("第一版", encoding="utf-8")
+    assert _run_git(project_root, "add", ".").returncode == 0
+    assert _run_git(project_root, "commit", "-m", "Chapter 1").returncode == 0
+    assert _run_git(project_root, "tag", "ch0001").returncode == 0
+
+    chapter_file.write_text("第二版", encoding="utf-8")
+    assert _run_git(project_root, "add", ".").returncode == 0
+    assert _run_git(project_root, "commit", "-m", "Chapter 2").returncode == 0
+    assert _run_git(project_root, "tag", "ch0002").returncode == 0
+    before_count = int(_run_git(project_root, "rev-list", "--count", "HEAD").stdout.strip())
+
+    manager = GitBackupManager(str(project_root))
+
+    assert manager.rollback(1) is True
+
+    assert _run_git(project_root, "symbolic-ref", "--short", "HEAD").stdout.strip() == "main"
+    assert chapter_file.read_text(encoding="utf-8") == "第一版"
+    after_count = int(_run_git(project_root, "rev-list", "--count", "HEAD").stdout.strip())
+    assert after_count == before_count + 1
+    assert "rollback: 恢复到 ch0001 备份点" in _run_git(project_root, "log", "-1", "--format=%s").stdout