Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions src/scripto/core/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/scripto/gui_qt/settings_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/scripto/i18n/en.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions src/scripto/i18n/zh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;依赖在下次启动时自动同步。",
Expand Down
33 changes: 33 additions & 0 deletions tests/test_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading