diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e0c7a1..80702a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ the name must be a single path segment that resolves to a direct child directory, so a symlink cannot redirect it, and FunPack cannot delete itself. Node packs register at import, so the panel says a restart is needed rather than restarting under you. + **Check for updates** fetches each pack's origin and shows how far behind it is, on the + row and on its Update button. It is a button rather than part of the listing because it + costs a network round trip per pack; the fetches run four at a time, and a pack that + cannot be compared says why (detached HEAD, no remote, origin unreachable) instead of + quietly reading as up to date. - **FunPack's loaders take `.gguf` files**, for diffusion models and text encoders alike, and a text-encoder list may mix a `.gguf` with `.safetensors` slots (the usual LTX-2.3 shape). diff --git a/movie_editor/backend/custom_nodes.py b/movie_editor/backend/custom_nodes.py index a7468cd9..f5c34d7a 100644 --- a/movie_editor/backend/custom_nodes.py +++ b/movie_editor/backend/custom_nodes.py @@ -205,3 +205,66 @@ def remove(name: str) -> dict: path = node_dir(name) shutil.rmtree(path) return {"name": name, "removed": True, "path": str(path)} + + +# ── checking for updates ────────────────────────────────────────────────────── + +def _behind_ahead(path: Path) -> dict: + """How far one pack is from its upstream, after fetching it. + + Costs a network round trip per pack, which is why it is a button rather than part of + the listing: on a tunnelled instance with a dozen packs, doing this on open would make + the panel feel broken. + """ + if not (path / ".git").exists(): + return {"checked": False, "reason": "not a git checkout"} + br = _git(path, "rev-parse", "--abbrev-ref", "HEAD", timeout=30) + branch = (br.stdout or "").strip() + if br.returncode != 0 or not branch or branch == "HEAD": + return {"checked": False, "reason": "not on a branch (detached HEAD)"} + remote = _git(path, "config", "--get", "remote.origin.url", timeout=30) + if remote.returncode != 0 or not (remote.stdout or "").strip(): + return {"checked": False, "reason": "no origin remote"} + fetched = _git(path, "fetch", "--quiet", "origin", branch, timeout=180) + if fetched.returncode != 0: + return {"checked": False, + "reason": (fetched.stderr or "could not reach origin").strip()[-200:]} + counts = _git(path, "rev-list", "--left-right", "--count", + f"HEAD...origin/{branch}", timeout=60) + if counts.returncode != 0: + return {"checked": False, "reason": "no upstream branch to compare against"} + parts = (counts.stdout or "").split() + if len(parts) != 2: + return {"checked": False, "reason": "could not compare with origin"} + ahead, behind = int(parts[0]), int(parts[1]) + return {"checked": True, "branch": branch, "ahead": ahead, "behind": behind} + + +def check_updates() -> dict: + """Fetch every git pack and report how far behind each one is. + + Fetches run concurrently and bounded: they are network-bound, so serial would be as slow + as the sum of them, and unbounded would open a connection per pack at once. + """ + from concurrent.futures import ThreadPoolExecutor + + packs = [n for n in list_nodes()["nodes"]] + targets = [p for p in packs if p.get("git")] + results: dict[str, dict] = {} + if targets: + with ThreadPoolExecutor(max_workers=4) as pool: + futures = {} + for p in targets: + try: + futures[pool.submit(_behind_ahead, node_dir(p["name"]))] = p["name"] + except CustomNodeError as e: + results[p["name"]] = {"checked": False, "reason": str(e)} + for fut, name in futures.items(): + try: + results[name] = fut.result() + except Exception as e: # noqa: BLE001 — one bad pack must not sink the sweep + results[name] = {"checked": False, "reason": str(e)[-200:]} + for p in packs: + if p["name"] not in results: + results[p["name"]] = {"checked": False, "reason": "not a git checkout"} + return {"checked": results} diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index cadfc077..a6a87789 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -98,6 +98,7 @@ clearFiles: (group) => j("POST", API(`/files/${encodeURIComponent(group)}/clear`), {}), nleLibrary: () => j("GET", API("/library/nle")), customNodes: () => j("GET", API("/custom-nodes")), + customNodesCheck: () => j("POST", API("/custom-nodes/check"), {}), customNodeInstall: (url) => j("POST", API("/custom-nodes/install"), { url }), customNodeUpdate: (name) => j("POST", API("/custom-nodes/update"), { name }), customNodeRemove: (name) => j("POST", API("/custom-nodes/remove"), { name }), diff --git a/movie_editor/frontend/custom_nodes.js b/movie_editor/frontend/custom_nodes.js index 7db1e057..fcf87d6e 100644 --- a/movie_editor/frontend/custom_nodes.js +++ b/movie_editor/frontend/custom_nodes.js @@ -16,6 +16,24 @@ let busy = ""; // name (or "__install__") of whatever is running let dirty = false; // something changed → a restart is needed let error = ""; + // name -> {checked, behind, ahead, reason}. Empty until the user asks: each entry costs a + // network round trip, and doing that on open would make the panel feel broken. + let checked = {}; + let checking = false; + + async function checkUpdates() { + if (checking || busy) return; + checking = true; + render(); + try { + checked = (await API.customNodesCheck()).checked || {}; + } catch (e) { + alert("Could not check for updates:\n\n" + (e?.message || e)); + } finally { + checking = false; + render(); + } + } async function refresh() { try { @@ -47,6 +65,9 @@ const res = await fn(); reportRequirements(res, what); dirty = true; + // Whatever this was, its behind-count is now stale. Dropping it beats showing a + // number that was true a moment ago. + if (key !== "__install__") delete checked[key]; } catch (e) { alert(`${what} failed:\n\n${e?.message || e}`); } finally { @@ -107,12 +128,23 @@ if (n.is_funpack) bits.push("FunPack itself"); if (n.git) bits.push(`${n.branch || "?"} · ${n.commit || "?"}`); else bits.push("not a git checkout"); + const chk = checked[n.name]; + if (chk && chk.checked) { + if (chk.behind > 0) bits.push(`${chk.behind} behind`); + else if (chk.ahead > 0) bits.push(`${chk.ahead} ahead of origin`); + else bits.push("up to date"); + } else if (chk && chk.reason) { + bits.push(`not compared — ${chk.reason}`); + } left.append(el("div", "cn-sub", bits.join(" — "))); r.append(left); const acts = el("div", "cn-row-acts"); + if (chk && chk.checked && chk.behind > 0) r.classList.add("behind"); if (!n.is_funpack) { - const up = el("button", "btn ghost tiny", busy === n.name ? "…" : "Update"); + const label = busy === n.name ? "…" + : (chk && chk.checked && chk.behind > 0) ? `Update (${chk.behind})` : "Update"; + const up = el("button", "btn ghost tiny" + (chk?.behind > 0 ? " primary" : ""), label); up.type = "button"; up.disabled = !!busy || !n.git; up.title = n.git ? `git pull in ${n.name}` : "Not a git checkout — nothing to pull"; @@ -142,8 +174,16 @@ add.disabled = !!busy; add.onclick = addDialog; bar.append(add); + const chkBtn = el("button", "btn ghost tiny", checking ? "Checking…" : "Check for updates"); + chkBtn.type = "button"; + chkBtn.disabled = checking || !!busy; + chkBtn.title = "Fetch each pack's origin and report how far behind it is"; + chkBtn.onclick = checkUpdates; + bar.append(chkBtn); if (busy) bar.append(el("span", "cn-busy", busy === "__install__" ? "Cloning and installing…" : `Working on ${busy}…`)); + else if (checking) bar.append(el("span", "cn-busy", + "Fetching each pack — this talks to the network.")); host.append(bar); if (dirty) { @@ -177,6 +217,7 @@ host = el("div", "cn-mount"); body.append(host); dirty = false; + checked = {}; render(); refresh(); return () => { host = null; }; diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 57ab348c..2d90d2ed 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -2704,6 +2704,8 @@ body.tour-mode { overflow: hidden; } } .cn-row.self { background: color-mix(in srgb, var(--accent) 10%, var(--ink-2)); border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); } +/* Behind origin — the one row state worth spotting without reading. */ +.cn-row.behind { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); } .cn-row-text { min-width: 0; } .cn-name { font-size: 12px; font-weight: 600; color: var(--text); overflow-wrap: anywhere; } diff --git a/movie_editor/server.py b/movie_editor/server.py index 25eaba08..8a783cee 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -2544,6 +2544,11 @@ if web is not None and PromptServer is not None: import asyncio return web.json_response(await asyncio.to_thread(custom_nodes.list_nodes)) + @routes.post(UI_PREFIX + "/api/custom-nodes/check") + async def _custom_nodes_check(_req): + import asyncio + return web.json_response(await asyncio.to_thread(custom_nodes.check_updates)) + @routes.post(UI_PREFIX + "/api/custom-nodes/install") async def _custom_nodes_install(req): import asyncio diff --git a/movie_editor/tests/test_custom_nodes.py b/movie_editor/tests/test_custom_nodes.py index cc97bd65..0343f15b 100644 --- a/movie_editor/tests/test_custom_nodes.py +++ b/movie_editor/tests/test_custom_nodes.py @@ -188,3 +188,94 @@ def test_listing_skips_dotfiles_and_disabled_packs(root, monkeypatch): names = [n["name"] for n in cn.list_nodes()["nodes"]] assert "__pycache__" not in names and ".git" not in names assert "Old.disabled" not in names + + +# ── check for updates ───────────────────────────────────────────────────────── +# A network round trip per pack, which is why it is a button. Every failure mode has to +# produce a reason rather than an absent answer, or a pack silently reads as "fine". + +def test_behind_ahead_parses_the_counts(root, monkeypatch): + (root / "SomePack" / ".git").mkdir() + + def fake_git(cwd, *args, **kw): + if args[0] == "rev-parse": + return types.SimpleNamespace(returncode=0, stdout="main\n", stderr="") + if args[0] == "config": + return types.SimpleNamespace(returncode=0, stdout="https://x/y\n", stderr="") + if args[0] == "fetch": + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + return types.SimpleNamespace(returncode=0, stdout="2\t7\n", stderr="") + + monkeypatch.setattr(cn, "_git", fake_git) + out = cn._behind_ahead(root / "SomePack") + assert out == {"checked": True, "branch": "main", "ahead": 2, "behind": 7} + + +def test_a_detached_head_says_so(root, monkeypatch): + (root / "SomePack" / ".git").mkdir() + monkeypatch.setattr(cn, "_git", lambda cwd, *a, **k: types.SimpleNamespace( + returncode=0, stdout="HEAD\n", stderr="")) + out = cn._behind_ahead(root / "SomePack") + assert out["checked"] is False and "detached" in out["reason"] + + +def test_an_unreachable_origin_says_so(root, monkeypatch): + (root / "SomePack" / ".git").mkdir() + + def fake_git(cwd, *args, **kw): + if args[0] == "rev-parse": + return types.SimpleNamespace(returncode=0, stdout="main\n", stderr="") + if args[0] == "config": + return types.SimpleNamespace(returncode=0, stdout="https://x/y\n", stderr="") + return types.SimpleNamespace(returncode=128, stdout="", + stderr="fatal: could not read from remote") + + monkeypatch.setattr(cn, "_git", fake_git) + out = cn._behind_ahead(root / "SomePack") + assert out["checked"] is False and "remote" in out["reason"] + + +def test_a_pack_with_no_remote_is_not_compared(root, monkeypatch): + (root / "SomePack" / ".git").mkdir() + + def fake_git(cwd, *args, **kw): + if args[0] == "rev-parse": + return types.SimpleNamespace(returncode=0, stdout="main\n", stderr="") + return types.SimpleNamespace(returncode=1, stdout="", stderr="") + + monkeypatch.setattr(cn, "_git", fake_git) + assert cn._behind_ahead(root / "SomePack")["reason"] == "no origin remote" + + +def test_a_non_git_pack_is_reported_not_omitted(root): + out = cn._behind_ahead(root / "SomePack") + assert out == {"checked": False, "reason": "not a git checkout"} + + +def test_every_pack_appears_in_the_result(root, monkeypatch): + """Including the ones that could not be checked — an absent entry would read as + 'nothing to say', which is not the same as 'could not tell'.""" + monkeypatch.setattr(cn, "_git_info", lambda p: { + "git": p.name == "ComfyUI-GGUF", "branch": "main", "commit": "abc", "remote": "x"}) + monkeypatch.setattr(cn, "_behind_ahead", + lambda p: {"checked": True, "branch": "main", "ahead": 0, "behind": 3}) + got = cn.check_updates()["checked"] + assert set(got) == {"ComfyUI-FunPack", "ComfyUI-GGUF", "SomePack"} + assert got["ComfyUI-GGUF"]["behind"] == 3 + assert got["SomePack"]["checked"] is False + + +def test_one_failing_pack_does_not_sink_the_sweep(root, monkeypatch): + monkeypatch.setattr(cn, "_git_info", lambda p: { + "git": True, "branch": "main", "commit": "abc", "remote": "x"}) + + def flaky(path): + if path.name == "SomePack": + raise RuntimeError("git exploded") + return {"checked": True, "branch": "main", "ahead": 0, "behind": 1} + + monkeypatch.setattr(cn, "_behind_ahead", flaky) + got = cn.check_updates()["checked"] + assert got["SomePack"]["checked"] is False + assert "exploded" in got["SomePack"]["reason"] + assert got["ComfyUI-GGUF"]["behind"] == 1