fix(cli): confirm local changes before upgrade

This commit is contained in:
jxxghp
2026-10-01 09:57:27 +08:00
parent a5da60f3fb
commit 19e62f891f
5 changed files with 120 additions and 7 deletions

View File

@@ -27,7 +27,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst
- 如果系统里已经有可用的 `Python 3.14+`,脚本会优先直接复用本地解释器
- 如果系统里没有可用解释器,脚本会通过最新稳定版 uv 安装 Python 3.14
- Linux 下安装系统依赖时通常需要 `sudo`
- 复用已有仓库时,脚本现在只会因为已跟踪源码改动而阻止自动更新,不会再被 `.DS_Store` 之类未跟踪文件卡住
- 复用已有仓库时,如果检测到已跟踪源码改动,脚本会先询问是否清空本地改动;确认后才会继续更新,拒绝或非交互模式会取消更新,不会删除被 Git 忽略的配置和运行时文件
如果安装完成后当前终端仍提示找不到 `moviepilot`:
@@ -357,6 +357,7 @@ moviepilot update all --skip-resources
说明:
- `update backend` 会更新 Git 仓库并重新安装后端依赖,包括 `moviepilot-rust` 加速扩展
- 后端更新前如果检测到已跟踪源码改动,CLI 会列出部分文件并询问是否清空;确认后执行清理并继续更新,拒绝或非交互模式会取消更新
- `MOVIEPILOT_UPDATE_DEV=true` 时,`moviepilot update` 默认使用 DEV 模式;`--dev` / `--no-dev` 可临时覆盖,省略目标时更新全部组件。进程环境变量优先于配置目录中的 `app.env`
- DEV 模式后端跟踪当前开发分支,处于 Release 的 detached HEAD 时回到 `v3`;前端下载最新 Release 的 `dist.zip`。显式 `--ref` / `--frontend-version` 优先于默认选择
- DEV 模式每次更新前端都会重新下载并替换发布包,即使版本号相同或显式指定了 `--frontend-version`,以获取同版本重新打包的内容

View File

@@ -202,6 +202,7 @@ Options:
说明:
- 更新后端依赖时会同步更新 moviepilot-rust PyPI 依赖
- 后端更新前如果检测到已跟踪源码改动,CLI 会询问是否清空本地改动;确认后才会继续更新,拒绝或非交互模式会取消更新
- 可在前端“高级设置 - 实验室”中关闭或重新开启 Rust 加速
EOF
}

View File

@@ -69,9 +69,19 @@ sync_repo() {
echo "==> 复用已有 MoviePilot 仓库: $APP_DIR"
if repo_dirty "$APP_DIR"; then
echo "检测到现有仓库包含未提交改动,已停止自动更新。" >&2
echo "请先清理 $APP_DIR 的本地修改,或换一个新的安装目录后重试。" >&2
exit 1
echo "检测到现有仓库包含未提交的源码改动。"
if [[ "$NON_INTERACTIVE" != "true" && "$HAS_TTY" == "true" ]] && \
prompt_yes_no "是否清空本地改动并继续更新" "n"; then
(
cd "$APP_DIR"
git reset --hard HEAD
)
echo "==> 已清理本地已跟踪源码改动,继续更新"
else
echo "已取消更新,未清理 $APP_DIR 的本地改动。" >&2
echo "请先提交或清理本地修改,或换一个新的安装目录后重试。" >&2
exit 1
fi
fi
(

View File

@@ -3789,6 +3789,7 @@ def _git_output(*args: str) -> str:
def _ensure_git_clean() -> None:
"""检查源码工作树,并在用户确认后清除已跟踪的本地改动。"""
status = _git_output("status", "--porcelain", "--untracked-files=no")
if not status.strip():
return
@@ -3806,9 +3807,18 @@ def _ensure_git_clean() -> None:
preview += " 等"
detail = f":{preview}"
raise RuntimeError(
f"检测到当前仓库有未提交的源码改动{detail},请先提交或清理后再执行更新。"
)
try:
confirmed = _prompt_yes_no(
f"检测到当前仓库有未提交的源码改动{detail},是否清空本地改动并继续更新",
default=False,
)
except (EOFError, OSError) as exc:
raise RuntimeError("当前终端不支持交互确认,已取消更新。") from exc
if not confirmed:
raise RuntimeError("已取消更新,未清理本地源码改动。")
print_step("清理本地已跟踪源码改动")
run(["git", "reset", "--hard", "HEAD"], cwd=ROOT)
def _update_backend_ref(ref: str, *, fetch: bool = True) -> str:

View File

@@ -0,0 +1,91 @@
"""本地 CLI 更新遇到源码改动时的确认与清理测试。"""
from __future__ import annotations
import importlib.util
import uuid
from pathlib import Path
import pytest
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py"
def load_local_setup_module():
"""加载独立的本地安装脚本模块,避免共享全局配置。"""
module_name = f"moviepilot_local_setup_update_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_dirty_update_clears_tracked_changes_after_confirmation(monkeypatch):
"""确认清理后应重置已跟踪源码改动,供后续 Git 更新继续。"""
module = load_local_setup_module()
prompts: list[tuple[str, bool]] = []
commands: list[list[str]] = []
monkeypatch.setattr(module, "_git_output", lambda *_args: " M app/example.py\n")
monkeypatch.setattr(
module,
"_prompt_yes_no",
lambda label, default: prompts.append((label, default)) or True,
)
monkeypatch.setattr(
module,
"run",
lambda command, cwd=None: commands.append(command),
)
module._ensure_git_clean()
assert prompts == [
(
"检测到当前仓库有未提交的源码改动:app/example.py,是否清空本地改动并继续更新",
False,
)
]
assert commands == [["git", "reset", "--hard", "HEAD"]]
def test_dirty_update_stops_when_local_change_cleanup_is_rejected(monkeypatch):
"""拒绝清理时应取消更新且不执行任何 Git 写操作。"""
module = load_local_setup_module()
commands: list[list[str]] = []
monkeypatch.setattr(module, "_git_output", lambda *_args: " M app/example.py\n")
monkeypatch.setattr(module, "_prompt_yes_no", lambda *_args, **_kwargs: False)
monkeypatch.setattr(
module,
"run",
lambda command, cwd=None: commands.append(command),
)
with pytest.raises(RuntimeError, match="已取消更新"):
module._ensure_git_clean()
assert commands == []
def test_dirty_update_stops_without_an_interactive_input(monkeypatch):
"""没有可用输入时应取消更新,而不是默认清除源码改动。"""
module = load_local_setup_module()
commands: list[list[str]] = []
monkeypatch.setattr(module, "_git_output", lambda *_args: " M app/example.py\n")
def raise_eof(*_args, **_kwargs):
"""模拟没有可用终端输入。"""
raise EOFError
monkeypatch.setattr(module, "_prompt_yes_no", raise_eof)
monkeypatch.setattr(
module,
"run",
lambda command, cwd=None: commands.append(command),
)
with pytest.raises(RuntimeError, match="不支持交互确认"):
module._ensure_git_clean()
assert commands == []