Skip to content

Commit 9131579

Browse files
authored
fix: resolve --version latest from local git tags before GitHub API (#441)
Signed-off-by: Alexander Piskun <bigcat88@icloud.com>
1 parent bfc454f commit 9131579

4 files changed

Lines changed: 767 additions & 13 deletions

File tree

comfy_cli/command/install.py

Lines changed: 138 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import platform
3+
import re
34
import subprocess
45
import sys
56
from typing import TypedDict
@@ -189,7 +190,7 @@ def execute(
189190

190191
if version != "nightly":
191192
try:
192-
checkout_stable_comfyui(version=version, repo_dir=repo_dir)
193+
checkout_stable_comfyui(version=version, repo_dir=repo_dir, url=url)
193194
except GitHubRateLimitError as e:
194195
rprint(f"[bold red]Error checking out ComfyUI version: {e}[/bold red]")
195196
sys.exit(1)
@@ -434,17 +435,136 @@ def clone_comfyui(url: str, repo_dir: str):
434435
subprocess.run(["git", "clone", url, repo_dir], check=True)
435436

436437

437-
def checkout_stable_comfyui(version: str, repo_dir: str):
438+
def _resolve_latest_tag_from_local(repo_dir: str) -> tuple[str | None, bool]:
439+
"""Pick the highest stable semver tag from the local clone.
440+
441+
Returns ``(tag, fetch_ok)``:
442+
- ``tag``: the tag string (e.g. ``"v0.20.1"``), or ``None`` when no stable
443+
semver tag is available (or the directory isn't a git repo).
444+
- ``fetch_ok``: whether ``git fetch --tags`` succeeded. Callers can use this
445+
to distinguish "no new releases" from "couldn't reach the remote", which
446+
changes the right messaging when falling back to the API.
447+
448+
Pre-release tags (e.g. ``v1.2.3-rc1``) are skipped to mirror GitHub's
449+
``releases/latest`` behavior. Note that this picks the highest semver tag,
450+
which may differ from the release a maintainer has manually marked as
451+
"Latest" on GitHub — acceptable trade-off given the unauthenticated API's
452+
60 req/hr per-IP cap; users can pin a specific version with ``--version``
453+
if needed.
454+
455+
``git_checkout_tag`` skips its own ``git fetch --tags`` when the resolved
456+
tag is already present locally, so on the happy path we fetch exactly once
457+
here. Crucially, that also lets the cached-tag offline path succeed: if
458+
fetch above fails (``fetch_ok=False``) but a tag is found from disk,
459+
``git_checkout_tag`` will not retry the unreachable fetch.
460+
"""
461+
fetch_ok = False
462+
try:
463+
completed = subprocess.run(
464+
["git", "-C", repo_dir, "fetch", "--tags", "--quiet"],
465+
capture_output=True,
466+
text=True,
467+
timeout=30,
468+
)
469+
fetch_ok = completed.returncode == 0
470+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
471+
# Tolerate timeout / OS-level failure; fall through with whatever's on disk.
472+
pass
473+
474+
try:
475+
result = subprocess.run(
476+
["git", "-C", repo_dir, "tag", "--list"],
477+
capture_output=True,
478+
text=True,
479+
check=True,
480+
timeout=10,
481+
)
482+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
483+
return None, fetch_ok
484+
485+
best: tuple[semver.VersionInfo, str] | None = None
486+
for line in result.stdout.splitlines():
487+
tag = line.strip()
488+
if not tag:
489+
continue
490+
try:
491+
parsed = semver.VersionInfo.parse(tag.lstrip("v"))
492+
except ValueError:
493+
continue
494+
if parsed.prerelease:
495+
continue
496+
if best is None or parsed > best[0]:
497+
best = (parsed, tag)
498+
499+
return (best[1] if best else None), fetch_ok
500+
501+
502+
_GITHUB_REPO_RE = re.compile(
503+
# `github.com[:/]<owner>/<repo>` with optional `.git` and optional setuptools-style
504+
# `@branch` suffix (matching what ``clone_comfyui`` accepts via ``rsplit("@", 1)``).
505+
# Branch names may contain slashes (`release/1.0`), so the `@<branch>` group is greedy
506+
# to end-of-string. The repo segment forbids `@` and `/` to avoid eating those parts.
507+
r"github\.com[/:]([^/\s]+)/([^/@\s]+?)(?:\.git)?(?:@.+)?/?$",
508+
)
509+
510+
511+
def _parse_github_owner_repo(url: str | None) -> tuple[str, str] | None:
512+
"""Parse a GitHub repo URL into ``(owner, repo)``.
513+
514+
Handles the URL forms ``clone_comfyui`` accepts:
515+
- ``https://github.com/owner/repo``
516+
- ``https://github.com/owner/repo.git``
517+
- ``https://github.com/owner/repo@branch`` (setuptools-style branch suffix)
518+
- ``git@github.com:owner/repo`` (SSH form)
519+
520+
Returns ``None`` for empty input, local paths, or non-GitHub URLs (GitLab,
521+
self-hosted, etc.) — the caller decides what to do with that.
522+
"""
523+
if not url:
524+
return None
525+
match = _GITHUB_REPO_RE.search(url)
526+
return (match.group(1), match.group(2)) if match else None
527+
528+
529+
def checkout_stable_comfyui(version: str, repo_dir: str, url: str | None = None):
438530
"""
439531
Supports installing stable releases of Comfy (semantic versioning) or the 'latest' version.
532+
533+
For ``version="latest"`` we resolve the highest stable semver tag from the
534+
local clone first to avoid burning the unauthenticated GitHub API budget
535+
(60 req/hr per IP). The ``releases/latest`` API is only consulted when local
536+
resolution turns up nothing.
537+
538+
The optional ``url`` is the install URL forwarded from ``execute``; it lets
539+
the API fallback query the same repo we cloned from (forks included)
540+
instead of always asking upstream. Non-GitHub URLs and missing URLs
541+
fall back to ``comfyanonymous/ComfyUI`` so the prior behavior is preserved
542+
for users who pass a local path or a non-GitHub remote.
440543
"""
441544
rprint(f"Looking for ComfyUI version '{version}'...")
442545
if version == "latest":
443-
selected_release = get_latest_release("comfyanonymous", "ComfyUI")
444-
if selected_release is None:
445-
rprint(f"Error: No release found for version '{version}'.")
446-
sys.exit(1)
447-
tag = str(selected_release["tag"])
546+
tag, fetch_ok = _resolve_latest_tag_from_local(repo_dir)
547+
if tag is None:
548+
if not fetch_ok:
549+
rprint(
550+
"[yellow]Could not refresh tags from the remote (offline or auth failure); "
551+
"trying GitHub API as a last resort.[/yellow]"
552+
)
553+
else:
554+
rprint("[yellow]No stable release tags found locally; querying GitHub API.[/yellow]")
555+
owner, repo = _parse_github_owner_repo(url) or ("comfyanonymous", "ComfyUI")
556+
selected_release = get_latest_release(owner, repo)
557+
if selected_release is None:
558+
rprint(f"Error: No release found for version '{version}'.")
559+
sys.exit(1)
560+
tag = str(selected_release["tag"])
561+
elif not fetch_ok:
562+
# Tag list comes from a cached state — flag it so the user knows
563+
# they may not be on the actual newest release.
564+
rprint(
565+
f"[yellow]Warning: could not refresh tags from remote; "
566+
f"using cached tag {tag}. Re-run with network access to get the newest release.[/yellow]"
567+
)
448568
else:
449569
# For specific versions, directly construct the tag (add 'v' prefix if needed)
450570
tag = f"v{version}" if not version.startswith("v") else version
@@ -490,9 +610,18 @@ def get_latest_release(repo_owner: str, repo_name: str) -> GithubRelease | None:
490610

491611
data = response.json()
492612

613+
# Forks may use non-semver tags (e.g. "release-2026-04"); the caller
614+
# only needs the raw tag string for git checkout, so let `version`
615+
# fall back to None instead of crashing.
616+
tag_name = data["tag_name"]
617+
try:
618+
parsed_version = semver.VersionInfo.parse(tag_name.lstrip("v"))
619+
except ValueError:
620+
parsed_version = None
621+
493622
return GithubRelease(
494-
tag=data["tag_name"],
495-
version=semver.VersionInfo.parse(data["tag_name"].lstrip("v")),
623+
tag=tag_name,
624+
version=parsed_version,
496625
download_url=data["zipball_url"],
497626
)
498627

comfy_cli/git_utils.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,35 @@ def git_checkout_tag(repo_path: str, tag: str) -> bool:
2828
"""
2929
Checkout a specific Git tag in the given repository.
3030
31+
Skips the network ``git fetch --tags`` when the tag already exists locally.
32+
This avoids a redundant round-trip on the happy path (the caller usually
33+
just cloned the repo or just ran a fetch via the resolver) and lets offline
34+
installs proceed when the tag is already cached. Only when the tag is
35+
absent locally do we attempt to fetch — and a failed fetch in that case is
36+
a real, unrecoverable error (``check=True`` surfaces it as before).
37+
3138
:param repo_path: Path to the Git repository
3239
:param tag: The tag to checkout
33-
:return: The output of the git command if successful, None if an error occurred
40+
:return: True if the checkout succeeds, False if any git command failed.
3441
"""
3542
original_dir = os.getcwd()
3643
try:
3744
# Change to the repository directory
3845

3946
os.chdir(repo_path)
4047

41-
# Fetch the latest tags
42-
subprocess.run(["git", "fetch", "--tags"], check=True, capture_output=True, text=True)
48+
# Skip the network fetch when the tag is already present locally.
49+
tag_present_locally = (
50+
subprocess.run(
51+
["git", "rev-parse", "--verify", f"refs/tags/{tag}"],
52+
capture_output=True,
53+
text=True,
54+
check=False,
55+
).returncode
56+
== 0
57+
)
58+
if not tag_present_locally:
59+
subprocess.run(["git", "fetch", "--tags"], check=True, capture_output=True, text=True)
4360

4461
# Checkout the specified tag
4562
subprocess.run(["git", "checkout", tag], check=True, capture_output=True, text=True)

0 commit comments

Comments
 (0)