From 67089448025a4fddff8bf987093f9e52ad39eb0e Mon Sep 17 00:00:00 2001 From: TN019 Date: Sun, 26 Jul 2026 19:50:38 +1000 Subject: [PATCH 1/2] Check updates becomes branch-aware: on a non-main checkout it says updates track main instead of a misleading 'up to date'. --- src/scripto/core/update.py | 39 +++++++++++++++++++++++++---- src/scripto/gui_qt/settings_page.py | 5 ++++ src/scripto/i18n/en.py | 1 + src/scripto/i18n/zh.py | 1 + tests/test_update.py | 12 +++++++++ 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/scripto/core/update.py b/src/scripto/core/update.py index 8a5484d..c567df4 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]: diff --git a/src/scripto/gui_qt/settings_page.py b/src/scripto/gui_qt/settings_page.py index 3df54ef..644dcd0 100644 --- a/src/scripto/gui_qt/settings_page.py +++ b/src/scripto/gui_qt/settings_page.py @@ -472,6 +472,11 @@ def apply() -> None: self.message.setText( self.t("gui.update_check_failed", detail=status.detail) ) + elif status.branch != status.release_branch: + self.message.setText(self.t( + "gui.update_on_branch", + branch=status.branch, main=status.release_branch, + )) elif status.behind == 0: self.message.setText(self.t("gui.update_uptodate")) elif status.dirty: diff --git a/src/scripto/i18n/en.py b/src/scripto/i18n/en.py index d76c236..3bb6b45 100644 --- a/src/scripto/i18n/en.py +++ b/src/scripto/i18n/en.py @@ -149,6 +149,7 @@ "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} — in-app updates only track {main}. Switch back to {main} to update from here.", "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..641a431 100644 --- a/src/scripto/i18n/zh.py +++ b/src/scripto/i18n/zh.py @@ -149,6 +149,7 @@ "gui.update_not_checkout": "当前副本不是从 git 检出运行的,无法自动更新。", "gui.update_running": "请先停止正在进行的任务再更新。", "gui.update_uptodate": "已是最新版本。", + "gui.update_on_branch": "当前检出在分支 {branch} 上——应用内更新只跟踪 {main}。切回 {main} 分支后即可在此更新。", "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..74f259f 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -72,3 +72,15 @@ 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 From 7cfcd6b4fe9bc74f8dfbd4f80b253f539dd82754 Mon Sep 17 00:00:00 2001 From: TN019 Date: Sun, 26 Jul 2026 19:52:54 +1000 Subject: [PATCH 2/2] The Update button now works from any branch: it checks out main, fast-forwards, and restarts; dirty checkouts are still refused untouched. --- src/scripto/core/update.py | 18 ++++++++++++++++++ src/scripto/gui_qt/settings_page.py | 9 ++++++++- src/scripto/i18n/en.py | 3 ++- src/scripto/i18n/zh.py | 3 ++- tests/test_update.py | 21 +++++++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/scripto/core/update.py b/src/scripto/core/update.py index c567df4..b3b9129 100644 --- a/src/scripto/core/update.py +++ b/src/scripto/core/update.py @@ -82,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 644dcd0..5359b4f 100644 --- a/src/scripto/gui_qt/settings_page.py +++ b/src/scripto/gui_qt/settings_page.py @@ -472,11 +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: @@ -502,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 3bb6b45..d118e7b 100644 --- a/src/scripto/i18n/en.py +++ b/src/scripto/i18n/en.py @@ -149,7 +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} — in-app updates only track {main}. Switch back to {main} to update from here.", + "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 641a431..bac254e 100644 --- a/src/scripto/i18n/zh.py +++ b/src/scripto/i18n/zh.py @@ -149,7 +149,8 @@ "gui.update_not_checkout": "当前副本不是从 git 检出运行的,无法自动更新。", "gui.update_running": "请先停止正在进行的任务再更新。", "gui.update_uptodate": "已是最新版本。", - "gui.update_on_branch": "当前检出在分支 {branch} 上——应用内更新只跟踪 {main}。切回 {main} 分支后即可在此更新。", + "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 74f259f..458a440 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -84,3 +84,24 @@ def test_check_flags_a_non_release_branch(tmp_path): 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"