diff --git a/docs/cli.md b/docs/cli.md index a1e06935c..c74b48681 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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`,以获取同版本重新打包的内容 diff --git a/moviepilot b/moviepilot index 900b36a03..23eebc211 100755 --- a/moviepilot +++ b/moviepilot @@ -202,6 +202,7 @@ Options: 说明: - 更新后端依赖时会同步更新 moviepilot-rust PyPI 依赖 + - 后端更新前如果检测到已跟踪源码改动,CLI 会询问是否清空本地改动;确认后才会继续更新,拒绝或非交互模式会取消更新 - 可在前端“高级设置 - 实验室”中关闭或重新开启 Rust 加速 EOF } diff --git a/scripts/bootstrap-local.sh b/scripts/bootstrap-local.sh index baaf56f25..896c812a4 100755 --- a/scripts/bootstrap-local.sh +++ b/scripts/bootstrap-local.sh @@ -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 ( diff --git a/scripts/local_setup.py b/scripts/local_setup.py index d7724c755..cbab2a263 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -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: diff --git a/tests/test_local_setup_update.py b/tests/test_local_setup_update.py new file mode 100644 index 000000000..4763be452 --- /dev/null +++ b/tests/test_local_setup_update.py @@ -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 == []