diff --git a/src/scripto/core/update.py b/src/scripto/core/update.py index 8a5484d..b3b9129 100644 --- a/src/scripto/core/update.py +++ b/src/scripto/core/update.py @@ -26,21 +26,50 @@ def repo_root() -> Path | None: @dataclass class UpdateStatus: ok: bool # the check itself succeeded - behind: int = 0 # commits behind upstream + behind: int = 0 # commits behind (upstream, or the release branch) dirty: bool = False # local uncommitted changes detail: str = "" # short human-readable error when not ok + branch: str = "" # currently checked-out branch + release_branch: str = "main" # the branch in-app updates track def check(root: Path) -> UpdateStatus: - """Fetches upstream and reports how far behind this checkout is.""" + """Fetches upstream and reports how far behind this checkout is. + + Updates track the release branch: on any other branch (a dev checkout) + the count is informational — HEAD vs the release branch — and the GUI + says "switch to main" instead of pretending everything is up to date. + """ dirty = _is_dirty(root) + branch_proc = _git(root, "rev-parse", "--abbrev-ref", "HEAD") + branch = branch_proc.stdout.strip() if branch_proc.returncode == 0 else "" fetch = _git(root, "fetch", "--quiet", timeout=120) if fetch.returncode != 0: - return UpdateStatus(ok=False, dirty=dirty, detail=_last_line(fetch.stderr)) + return UpdateStatus(ok=False, dirty=dirty, branch=branch, + detail=_last_line(fetch.stderr)) + + release = _release_branch(root) + if branch != release: + behind = _git(root, "rev-list", "--count", f"HEAD..origin/{release}") + count = int(behind.stdout.strip() or "0") if behind.returncode == 0 else 0 + return UpdateStatus(ok=True, behind=count, dirty=dirty, + branch=branch, release_branch=release) + behind = _git(root, "rev-list", "--count", "HEAD..@{upstream}") if behind.returncode != 0: - return UpdateStatus(ok=False, dirty=dirty, detail=_last_line(behind.stderr)) - return UpdateStatus(ok=True, behind=int(behind.stdout.strip() or "0"), dirty=dirty) + return UpdateStatus(ok=False, dirty=dirty, branch=branch, + release_branch=release, + detail=_last_line(behind.stderr)) + return UpdateStatus(ok=True, behind=int(behind.stdout.strip() or "0"), + dirty=dirty, branch=branch, release_branch=release) + + +def _release_branch(root: Path) -> str: + """The remote's default branch (what updates follow); main as fallback.""" + proc = _git(root, "symbolic-ref", "--short", "refs/remotes/origin/HEAD") + if proc.returncode == 0 and proc.stdout.strip(): + return proc.stdout.strip().split("/", 1)[-1] + return "main" def pull(root: Path) -> tuple[bool, str]: @@ -53,6 +82,24 @@ def pull(root: Path) -> tuple[bool, str]: return True, _last_line(proc.stdout) +def update_to_release(root: Path) -> tuple[bool, str]: + """Checkout the release branch (if needed) and fast-forward it. + + The one entry point the GUI's Update button uses: from main it is just + a pull; from a dev branch it switches to main first. Same safety + contract as pull() — a dirty tree is refused, never touched. + """ + if _is_dirty(root): + return False, "local changes" + release = _release_branch(root) + current = _git(root, "rev-parse", "--abbrev-ref", "HEAD") + if current.stdout.strip() != release: + proc = _git(root, "checkout", release, timeout=120) + if proc.returncode != 0: + return False, _last_line(proc.stderr) + return pull(root) + + def spawn_restart(root: Path) -> None: """Starts a fresh detached ``uv run scripto``; the caller then closes this instance. The new process re-resolves dependencies, so a pulled diff --git a/src/scripto/gui_qt/settings_page.py b/src/scripto/gui_qt/settings_page.py index 3df54ef..5359b4f 100644 --- a/src/scripto/gui_qt/settings_page.py +++ b/src/scripto/gui_qt/settings_page.py @@ -472,6 +472,18 @@ def apply() -> None: self.message.setText( self.t("gui.update_check_failed", detail=status.detail) ) + elif status.dirty and status.branch != status.release_branch: + self.message.setText(self.t( + "gui.update_branch_dirty", branch=status.branch, + )) + elif status.branch != status.release_branch: + self.message.setText(self.t( + "gui.update_on_branch", + branch=status.branch, main=status.release_branch, + )) + self.note.setText(self.t("gui.update_restart_note")) + self.note.show() + self.update_btn.show() elif status.behind == 0: self.message.setText(self.t("gui.update_uptodate")) elif status.dirty: @@ -497,7 +509,7 @@ def _do_update(self) -> None: root = up.repo_root() def job() -> None: - ok, detail = up.pull(root) + ok, detail = up.update_to_release(root) def apply() -> None: if not ok: diff --git a/src/scripto/i18n/en.py b/src/scripto/i18n/en.py index d76c236..d118e7b 100644 --- a/src/scripto/i18n/en.py +++ b/src/scripto/i18n/en.py @@ -149,6 +149,8 @@ "gui.update_not_checkout": "This copy does not run from a git checkout, so it cannot self-update.", "gui.update_running": "Stop the current run before updating.", "gui.update_uptodate": "Already up to date.", + "gui.update_on_branch": "This checkout is on branch {branch}. Updating switches to {main} and pulls the latest code.", + "gui.update_branch_dirty": "This checkout is on branch {branch} with local changes — commit or stash them, then update.", "gui.update_behind": "{count} new commit(s) available.", "gui.update_dirty": "{count} new commit(s) available, but the checkout has local changes — update it manually (commit or stash first).", "gui.update_restart_note": "Updating pulls the latest code and restarts Scripto; dependencies sync on the next launch.", diff --git a/src/scripto/i18n/zh.py b/src/scripto/i18n/zh.py index 0f89bf7..bac254e 100644 --- a/src/scripto/i18n/zh.py +++ b/src/scripto/i18n/zh.py @@ -149,6 +149,8 @@ "gui.update_not_checkout": "当前副本不是从 git 检出运行的,无法自动更新。", "gui.update_running": "请先停止正在进行的任务再更新。", "gui.update_uptodate": "已是最新版本。", + "gui.update_on_branch": "当前检出在分支 {branch} 上。更新将切换到 {main} 并拉取最新代码。", + "gui.update_branch_dirty": "当前分支 {branch} 有未提交的本地改动——请先提交或 stash,再更新。", "gui.update_behind": "有 {count} 个新提交可更新。", "gui.update_dirty": "有 {count} 个新提交,但检出目录有本地改动——请先手动处理(提交或 stash)再更新。", "gui.update_restart_note": "更新会拉取最新代码并重启 Scripto;依赖在下次启动时自动同步。", diff --git a/tests/test_update.py b/tests/test_update.py index d0e555b..458a440 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -72,3 +72,36 @@ def test_pull_refuses_a_dirty_clone(tmp_path): assert not ok assert detail == "local changes" assert not (clone / "two.txt").exists() + + +def test_check_flags_a_non_release_branch(tmp_path): + upstream, clone = _make_pair(tmp_path) + _git(clone, "checkout", "-b", "feature") + _commit(upstream, "two.txt") + + status = update.check(clone) + assert status.ok + assert status.branch == "feature" + assert status.release_branch == "main" + assert status.behind == 1 # informational: main moved on without us + + +def test_update_to_release_switches_to_main_and_pulls(tmp_path): + upstream, clone = _make_pair(tmp_path) + _git(clone, "checkout", "-b", "feature") + _commit(upstream, "two.txt") + + ok, _detail = update.update_to_release(clone) + assert ok + assert _git(clone, "rev-parse", "--abbrev-ref", "HEAD").strip() == "main" + assert (clone / "two.txt").is_file() # latest release code is present + + +def test_update_to_release_refuses_a_dirty_branch(tmp_path): + _upstream, clone = _make_pair(tmp_path) + _git(clone, "checkout", "-b", "feature") + (clone / "wip.txt").write_text("wip", encoding="utf-8") + + ok, detail = update.update_to_release(clone) + assert not ok and detail == "local changes" + assert _git(clone, "rev-parse", "--abbrev-ref", "HEAD").strip() == "feature"