|
1 | 1 | import os |
2 | 2 | import platform |
| 3 | +import re |
3 | 4 | import subprocess |
4 | 5 | import sys |
5 | 6 | from typing import TypedDict |
@@ -189,7 +190,7 @@ def execute( |
189 | 190 |
|
190 | 191 | if version != "nightly": |
191 | 192 | try: |
192 | | - checkout_stable_comfyui(version=version, repo_dir=repo_dir) |
| 193 | + checkout_stable_comfyui(version=version, repo_dir=repo_dir, url=url) |
193 | 194 | except GitHubRateLimitError as e: |
194 | 195 | rprint(f"[bold red]Error checking out ComfyUI version: {e}[/bold red]") |
195 | 196 | sys.exit(1) |
@@ -434,17 +435,136 @@ def clone_comfyui(url: str, repo_dir: str): |
434 | 435 | subprocess.run(["git", "clone", url, repo_dir], check=True) |
435 | 436 |
|
436 | 437 |
|
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): |
438 | 530 | """ |
439 | 531 | 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. |
440 | 543 | """ |
441 | 544 | rprint(f"Looking for ComfyUI version '{version}'...") |
442 | 545 | 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 | + ) |
448 | 568 | else: |
449 | 569 | # For specific versions, directly construct the tag (add 'v' prefix if needed) |
450 | 570 | 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: |
490 | 610 |
|
491 | 611 | data = response.json() |
492 | 612 |
|
| 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 | + |
493 | 622 | 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, |
496 | 625 | download_url=data["zipball_url"], |
497 | 626 | ) |
498 | 627 |
|
|
0 commit comments