Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
cache.py753 linesDownload Raw Back to cli
1# Copyright 2025-present, the HuggingFace Inc. team.2#3# Licensed under the Apache License, Version 2.0 (the "License");4# you may not use this file except in compliance with the License.5# You may obtain a copy of the License at6#7#     http://www.apache.org/licenses/LICENSE-2.08#9# Unless required by applicable law or agreed to in writing, software10# distributed under the License is distributed on an "AS IS" BASIS,11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12# See the License for the specific language governing permissions and13# limitations under the License.14"""Contains the 'hf cache' command group with cache management subcommands."""15 16import re17import time18from collections import defaultdict19from collections.abc import Callable, Mapping20from dataclasses import dataclass21from enum import Enum22from typing import Annotated, Any23 24import typer25 26from huggingface_hub.errors import CLIError27 28from ..utils import ANSI, CachedRepoInfo, CachedRevisionInfo, CacheNotFound, HFCacheInfo, _format_size, scan_cache_dir29from ..utils._parsing import parse_duration, parse_size30from ._cli_utils import RepoIdArg, RepoTypeOpt, RevisionOpt, TokenOpt, get_hf_api, typer_factory31from ._output import out32 33 34cache_cli = typer_factory(help="Manage local cache directory.")35 36 37#### Cache helper utilities38 39 40@dataclass(frozen=True)41class _DeletionResolution:42    revisions: frozenset[str]43    selected: dict[CachedRepoInfo, frozenset[CachedRevisionInfo]]44    missing: tuple[str, ...]45 46 47_FILTER_PATTERN = re.compile(r"^(?P<key>[a-zA-Z_]+)\s*(?P<op>==|!=|>=|<=|>|<|=)\s*(?P<value>.+)$")48_ALLOWED_OPERATORS = {"=", "!=", ">", "<", ">=", "<="}49_FILTER_KEYS = {"accessed", "modified", "refs", "size", "type"}50_SORT_KEYS = {"accessed", "modified", "name", "size"}51_SORT_PATTERN = re.compile(r"^(?P<key>[a-zA-Z_]+)(?::(?P<order>asc|desc))?$")52_SORT_DEFAULT_ORDER = {53    # Default ordering: accessed/modified/size are descending (newest/biggest first), name is ascending54    "accessed": "desc",55    "modified": "desc",56    "size": "desc",57    "name": "asc",58}59 60 61# Dynamically generate SortOptions enum from _SORT_KEYS62_sort_options_dict = {}63for key in sorted(_SORT_KEYS):64    _sort_options_dict[key] = key65    _sort_options_dict[f"{key}_asc"] = f"{key}:asc"66    _sort_options_dict[f"{key}_desc"] = f"{key}:desc"67 68SortOptions = Enum("SortOptions", _sort_options_dict, type=str, module=__name__)  # type: ignore69 70 71@dataclass(frozen=True)72class CacheDeletionCounts:73    """Simple counters summarizing cache deletions for CLI messaging."""74 75    repo_count: int76    partial_revision_count: int77    total_revision_count: int78 79 80CacheEntry = tuple[CachedRepoInfo, CachedRevisionInfo | None]81RepoRefsMap = dict[CachedRepoInfo, frozenset[str]]82 83 84def summarize_deletions(85    selected_by_repo: Mapping[CachedRepoInfo, frozenset[CachedRevisionInfo]],86) -> CacheDeletionCounts:87    """Summarize deletions across repositories."""88    repo_count = 089    total_revisions = 090    revisions_in_full_repos = 091 92    for repo, revisions in selected_by_repo.items():93        total_revisions += len(revisions)94        if len(revisions) == len(repo.revisions):95            repo_count += 196            revisions_in_full_repos += len(revisions)97 98    partial_revision_count = total_revisions - revisions_in_full_repos99    return CacheDeletionCounts(repo_count, partial_revision_count, total_revisions)100 101 102def print_cache_selected_revisions(selected_by_repo: Mapping[CachedRepoInfo, frozenset[CachedRevisionInfo]]) -> None:103    """Pretty-print selected cache revisions during confirmation prompts."""104    for repo in sorted(selected_by_repo.keys(), key=lambda repo: (repo.repo_type, repo.repo_id.lower())):105        repo_key = f"{repo.repo_type}/{repo.repo_id}"106        revisions = sorted(selected_by_repo[repo], key=lambda rev: rev.commit_hash)107        if len(revisions) == len(repo.revisions):108            out.text(f"  - {repo_key} (entire repo)")109            continue110 111        out.text(f"  - {repo_key}:")112        for revision in revisions:113            refs = " ".join(sorted(revision.refs)) or "(detached)"114            out.text(f"      {revision.commit_hash} [{refs}] {revision.size_on_disk_str}")115 116 117def build_cache_index(118    hf_cache_info: HFCacheInfo,119) -> tuple[120    dict[str, CachedRepoInfo],121    dict[str, tuple[CachedRepoInfo, CachedRevisionInfo]],122]:123    """Create lookup tables so CLI commands can resolve repo ids and revisions quickly."""124    repo_lookup: dict[str, CachedRepoInfo] = {}125    revision_lookup: dict[str, tuple[CachedRepoInfo, CachedRevisionInfo]] = {}126    for repo in hf_cache_info.repos:127        repo_key = repo.cache_id.lower()128        repo_lookup[repo_key] = repo129        for revision in repo.revisions:130            revision_lookup[revision.commit_hash.lower()] = (repo, revision)131    return repo_lookup, revision_lookup132 133 134def collect_cache_entries(135    hf_cache_info: HFCacheInfo, *, include_revisions: bool136) -> tuple[list[CacheEntry], RepoRefsMap]:137    """Flatten cache metadata into rows consumed by `hf cache ls`."""138    entries: list[CacheEntry] = []139    repo_refs_map: RepoRefsMap = {}140    sorted_repos = sorted(hf_cache_info.repos, key=lambda repo: (repo.repo_type, repo.repo_id.lower()))141    for repo in sorted_repos:142        repo_refs_map[repo] = frozenset({ref for revision in repo.revisions for ref in revision.refs})143        if include_revisions:144            for revision in sorted(repo.revisions, key=lambda rev: rev.commit_hash):145                entries.append((repo, revision))146        else:147            entries.append((repo, None))148    if include_revisions:149        entries.sort(150            key=lambda entry: (151                entry[0].cache_id,152                entry[1].commit_hash if entry[1] is not None else "",153            )154        )155    else:156        entries.sort(key=lambda entry: entry[0].cache_id)157    return entries, repo_refs_map158 159 160def compile_cache_filter(161    expr: str, repo_refs_map: RepoRefsMap162) -> Callable[[CachedRepoInfo, CachedRevisionInfo | None, float], bool]:163    """Convert a `hf cache ls` filter expression into the yes/no test we apply to each cache entry before displaying it."""164    match = _FILTER_PATTERN.match(expr.strip())165    if not match:166        raise ValueError(f"Invalid filter expression: '{expr}'.")167 168    key = match.group("key").lower()169    op = match.group("op")170    value_raw = match.group("value").strip()171 172    if op not in _ALLOWED_OPERATORS:173        raise ValueError(f"Unsupported operator '{op}' in filter '{expr}'. Must be one of {list(_ALLOWED_OPERATORS)}.")174 175    if key not in _FILTER_KEYS:176        raise ValueError(f"Unsupported filter key '{key}' in '{expr}'. Must be one of {list(_FILTER_KEYS)}.")177    # at this point we know that key is in `_FILTER_KEYS`178    if key == "size":179        size_threshold = parse_size(value_raw)180        return lambda repo, revision, _: _compare_numeric(181            revision.size_on_disk if revision is not None else repo.size_on_disk,182            op,183            size_threshold,184        )185 186    if key in {"modified", "accessed"}:187        seconds = parse_duration(value_raw.strip())188 189        def _time_filter(repo: CachedRepoInfo, revision: CachedRevisionInfo | None, now: float) -> bool:190            timestamp = (191                repo.last_accessed192                if key == "accessed"193                else revision.last_modified194                if revision is not None195                else repo.last_modified196            )197            if timestamp is None:198                return False199            return _compare_numeric(now - timestamp, op, seconds)200 201        return _time_filter202 203    if key == "type":204        expected = value_raw.lower()205 206        if op != "=":207            raise ValueError(f"Only '=' is supported for 'type' filters. Got '{op}'.")208 209        def _type_filter(repo: CachedRepoInfo, revision: CachedRevisionInfo | None, _: float) -> bool:210            return repo.repo_type.lower() == expected211 212        return _type_filter213 214    else:  # key == "refs"215        if op != "=":216            raise ValueError(f"Only '=' is supported for 'refs' filters. Got {op}.")217 218        def _refs_filter(repo: CachedRepoInfo, revision: CachedRevisionInfo | None, _: float) -> bool:219            refs = revision.refs if revision is not None else repo_refs_map.get(repo, frozenset())220            return value_raw.lower() in [ref.lower() for ref in refs]221 222        return _refs_filter223 224 225def _compare_numeric(left: float | None, op: str, right: float) -> bool:226    """Evaluate numeric comparisons for filters."""227    if left is None:228        return False229 230    comparisons = {231        "=": left == right,232        "!=": left != right,233        ">": left > right,234        "<": left < right,235        ">=": left >= right,236        "<=": left <= right,237    }238 239    if op not in comparisons:240        raise ValueError(f"Unsupported numeric comparison operator: {op}")241 242    return comparisons[op]243 244 245def compile_cache_sort(sort_expr: str) -> tuple[Callable[[CacheEntry], tuple[Any, ...]], bool]:246    """Convert a `hf cache ls` sort expression into a key function for sorting entries.247 248    Returns:249        A tuple of (key_function, reverse_flag) where reverse_flag indicates whether250        to sort in descending order (True) or ascending order (False).251    """252    match = _SORT_PATTERN.match(sort_expr.strip().lower())253    if not match:254        raise ValueError(f"Invalid sort expression: '{sort_expr}'. Expected format: 'key' or 'key:asc' or 'key:desc'.")255 256    key = match.group("key").lower()257    explicit_order = match.group("order")258 259    if key not in _SORT_KEYS:260        raise ValueError(f"Unsupported sort key '{key}' in '{sort_expr}'. Must be one of {list(_SORT_KEYS)}.")261 262    # Use explicit order if provided, otherwise use default for the key263    order = explicit_order if explicit_order else _SORT_DEFAULT_ORDER[key]264    reverse = order == "desc"265 266    def _sort_key(entry: CacheEntry) -> tuple[Any, ...]:267        repo, revision = entry268 269        if key == "name":270            # Sort by cache_id (repo type/id)271            value: Any = repo.cache_id.lower()272            return (value,)273 274        if key == "size":275            # Use revision size if available, otherwise repo size276            value = revision.size_on_disk if revision is not None else repo.size_on_disk277            return (value,)278 279        if key == "accessed":280            # For revisions, accessed is not available per-revision, use repo's last_accessed281            # For repos, use repo's last_accessed282            value = repo.last_accessed if repo.last_accessed is not None else 0.0283            return (value,)284 285        if key == "modified":286            # Use revision's last_modified if available, otherwise repo's last_modified287            if revision is not None:288                value = revision.last_modified if revision.last_modified is not None else 0.0289            else:290                value = repo.last_modified if repo.last_modified is not None else 0.0291            return (value,)292 293        # Should never reach here due to validation above294        raise ValueError(f"Unsupported sort key: {key}")295 296    return _sort_key, reverse297 298 299def _resolve_deletion_targets(hf_cache_info: HFCacheInfo, targets: list[str]) -> _DeletionResolution:300    """Resolve the deletion targets into a deletion resolution."""301    repo_lookup, revision_lookup = build_cache_index(hf_cache_info)302 303    selected: dict[CachedRepoInfo, set[CachedRevisionInfo]] = defaultdict(set)304    revisions: set[str] = set()305    missing: list[str] = []306 307    for raw_target in targets:308        target = raw_target.strip()309        if not target:310            continue311        lowered = target.lower()312 313        if re.fullmatch(r"[0-9a-fA-F]{40}", lowered):314            match = revision_lookup.get(lowered)315            if match is None:316                missing.append(raw_target)317                continue318            repo, revision = match319            selected[repo].add(revision)320            revisions.add(revision.commit_hash)321            continue322 323        matched_repo = repo_lookup.get(lowered)324        if matched_repo is None:325            missing.append(raw_target)326            continue327 328        for revision in matched_repo.revisions:329            selected[matched_repo].add(revision)330            revisions.add(revision.commit_hash)331 332    frozen_selected = {repo: frozenset(revs) for repo, revs in selected.items()}333    return _DeletionResolution(334        revisions=frozenset(revisions),335        selected=frozen_selected,336        missing=tuple(missing),337    )338 339 340#### Cache CLI commands341 342 343@cache_cli.command(344    "list | ls",345    examples=[346        "hf cache ls",347        "hf cache ls --revisions",348        'hf cache ls --filter "size>1GB" --limit 20',349        "hf cache ls --format json",350    ],351)352def ls(353    cache_dir: Annotated[354        str | None,355        typer.Option(356            help="Cache directory to scan (defaults to Hugging Face cache).",357        ),358    ] = None,359    revisions: Annotated[360        bool,361        typer.Option(362            help="Include revisions in the output instead of aggregated repositories.",363        ),364    ] = False,365    filter: Annotated[366        list[str] | None,367        typer.Option(368            "-f",369            "--filter",370            help="Filter entries (e.g. 'size>1GB', 'type=model', 'accessed>7d'). Can be used multiple times.",371        ),372    ] = None,373    sort: Annotated[374        SortOptions | None,375        typer.Option(376            help="Sort entries by key. Supported keys: 'accessed', 'modified', 'name', 'size'. "377            "Append ':asc' or ':desc' to explicitly set the order (e.g., 'modified:asc'). "378            "Defaults: 'accessed', 'modified', 'size' default to 'desc' (newest/biggest first); "379            "'name' defaults to 'asc' (alphabetical).",380        ),381    ] = None,382    limit: Annotated[383        int | None,384        typer.Option(385            help="Limit the number of results returned. Returns only the top N entries after sorting.",386        ),387    ] = None,388) -> None:389    """List cached repositories or revisions."""390    try:391        hf_cache_info = scan_cache_dir(cache_dir)392    except CacheNotFound as exc:393        raise CLIError(f"Cache directory not found: {exc.cache_dir}") from exc394 395    filters = filter or []396 397    entries, repo_refs_map = collect_cache_entries(hf_cache_info, include_revisions=revisions)398    try:399        filter_fns = [compile_cache_filter(expr, repo_refs_map) for expr in filters]400    except ValueError as exc:401        raise typer.BadParameter(str(exc)) from exc402 403    now = time.time()404    for fn in filter_fns:405        entries = [entry for entry in entries if fn(entry[0], entry[1], now)]406 407    # Apply sorting if requested408    if sort:409        try:410            sort_key_fn, reverse = compile_cache_sort(sort.value)411            entries.sort(key=sort_key_fn, reverse=reverse)412        except ValueError as exc:413            raise typer.BadParameter(str(exc)) from exc414 415    # Apply limit if requested416    if limit is not None:417        if limit < 0:418            raise typer.BadParameter(f"Limit must be a positive integer, got {limit}.")419        entries = entries[:limit]420 421    if revisions:422        items = [423            {424                "id": repo.cache_id,425                "repo_id": repo.repo_id,426                "repo_type": repo.repo_type,427                "revision": revision.commit_hash,428                "snapshot_path": str(revision.snapshot_path),429                "size": revision.size_on_disk_str,430                "last_modified": revision.last_modified_str,431                "refs": sorted(revision.refs),432            }433            for repo, revision in entries434            if revision is not None435        ]436        out.table(437            items,438            headers=["id", "revision", "size", "last_modified", "refs"],439            id_key="revision",440            alignments={"size": "right"},441        )442    else:443        items = [444            {445                "id": repo.cache_id,446                "repo_id": repo.repo_id,447                "repo_type": repo.repo_type,448                "size": repo.size_on_disk_str,449                "last_accessed": repo.last_accessed_str or "",450                "last_modified": repo.last_modified_str,451                "refs": sorted(repo_refs_map.get(repo, frozenset())),452            }453            for repo, _ in entries454        ]455        out.table(456            items,457            headers=["id", "size", "last_accessed", "last_modified", "refs"],458            id_key="id",459            alignments={"size": "right"},460        )461 462    if entries:463        unique_repos = {repo for repo, _ in entries}464        repo_count = len(unique_repos)465        if revisions:466            revision_count = sum(1 for _, rev in entries if rev is not None)467            total_size = sum(rev.size_on_disk for _, rev in entries if rev is not None)468        else:469            revision_count = sum(len(repo.revisions) for repo in unique_repos)470            total_size = sum(repo.size_on_disk for repo in unique_repos)471        out.text(472            ANSI.bold(473                f"\nFound {repo_count} repo(s) for a total of {revision_count} revision(s)"474                f" and {_format_size(total_size)} on disk."475            )476        )477 478 479@cache_cli.command(480    examples=[481        "hf cache rm model/gpt2",482        "hf cache rm <revision_hash>",483        "hf cache rm model/gpt2 --dry-run",484        "hf cache rm model/gpt2 --yes",485    ],486)487def rm(488    targets: Annotated[489        list[str],490        typer.Argument(491            help="One or more repo IDs (e.g. model/bert-base-uncased) or revision hashes to delete.",492        ),493    ],494    cache_dir: Annotated[495        str | None,496        typer.Option(497            help="Cache directory to scan (defaults to Hugging Face cache).",498        ),499    ] = None,500    yes: Annotated[501        bool,502        typer.Option(503            "-y",504            "--yes",505            help="Skip confirmation prompt.",506        ),507    ] = False,508    dry_run: Annotated[509        bool,510        typer.Option(511            help="Preview deletions without removing anything.",512        ),513    ] = False,514) -> None:515    """Remove cached repositories or revisions."""516    try:517        hf_cache_info = scan_cache_dir(cache_dir)518    except CacheNotFound as exc:519        raise CLIError(f"Cache directory not found: {exc.cache_dir}") from exc520 521    resolution = _resolve_deletion_targets(hf_cache_info, targets)522 523    if resolution.missing:524        details = "\n".join(f"  - {entry}" for entry in resolution.missing)525        out.warning(f"Could not find in cache:\n{details}")526 527    if len(resolution.revisions) == 0:528        out.text("Nothing to delete.")529        raise typer.Exit(code=0)530 531    strategy = hf_cache_info.delete_revisions(*sorted(resolution.revisions))532    counts = summarize_deletions(resolution.selected)533 534    summary_parts: list[str] = []535    if counts.repo_count:536        summary_parts.append(f"{counts.repo_count} repo(s)")537    if counts.partial_revision_count:538        summary_parts.append(f"{counts.partial_revision_count} revision(s)")539    if not summary_parts:540        summary_parts.append(f"{counts.total_revision_count} revision(s)")541 542    summary_text = " and ".join(summary_parts)543    out.text(f"About to delete {summary_text} totalling {strategy.expected_freed_size_str}.")544    print_cache_selected_revisions(resolution.selected)545 546    if dry_run:547        out.result(548            "Dry run: no files were deleted.",549            dry_run=True,550            repos=counts.repo_count,551            revisions=counts.total_revision_count,552            size=strategy.expected_freed_size_str,553        )554        return555 556    out.confirm("Proceed with deletion?", yes=yes)557 558    strategy.execute()559    counts = summarize_deletions(resolution.selected)560    out.result(561        f"Deleted {counts.repo_count} repo(s) and {counts.total_revision_count} revision(s);"562        f" freed {strategy.expected_freed_size_str}.",563        repos_deleted=counts.repo_count,564        revisions_deleted=counts.total_revision_count,565        freed=strategy.expected_freed_size_str,566    )567 568 569@cache_cli.command(examples=["hf cache prune", "hf cache prune --dry-run"])570def prune(571    cache_dir: Annotated[572        str | None,573        typer.Option(574            help="Cache directory to scan (defaults to Hugging Face cache).",575        ),576    ] = None,577    yes: Annotated[578        bool,579        typer.Option(580            "-y",581            "--yes",582            help="Skip confirmation prompt.",583        ),584    ] = False,585    dry_run: Annotated[586        bool,587        typer.Option(588            help="Preview deletions without removing anything.",589        ),590    ] = False,591) -> None:592    """Remove detached revisions from the cache."""593    try:594        hf_cache_info = scan_cache_dir(cache_dir)595    except CacheNotFound as exc:596        raise CLIError(f"Cache directory not found: {exc.cache_dir}") from exc597 598    selected: dict[CachedRepoInfo, frozenset[CachedRevisionInfo]] = {}599    revisions: set[str] = set()600    for repo in hf_cache_info.repos:601        detached = frozenset(revision for revision in repo.revisions if len(revision.refs) == 0)602        if not detached:603            continue604        selected[repo] = detached605        revisions.update(revision.commit_hash for revision in detached)606 607    if len(revisions) == 0:608        out.text("No unreferenced revisions found. Nothing to prune.")609        return610 611    resolution = _DeletionResolution(612        revisions=frozenset(revisions),613        selected=selected,614        missing=(),615    )616    strategy = hf_cache_info.delete_revisions(*sorted(resolution.revisions))617    counts = summarize_deletions(selected)618 619    out.text(620        f"About to delete {counts.total_revision_count} unreferenced revision(s) ({strategy.expected_freed_size_str} total)."621    )622    print_cache_selected_revisions(selected)623 624    if dry_run:625        out.result(626            "Dry run: no files were deleted.",627            dry_run=True,628            revisions=counts.total_revision_count,629            size=strategy.expected_freed_size_str,630        )631        return632 633    out.confirm("Proceed?", yes=yes)634 635    strategy.execute()636    out.result(637        f"Deleted {counts.total_revision_count} unreferenced revision(s); freed {strategy.expected_freed_size_str}.",638        revisions_deleted=counts.total_revision_count,639        freed=strategy.expected_freed_size_str,640    )641 642 643@cache_cli.command(644    examples=[645        "hf cache verify gpt2",646        "hf cache verify gpt2 --revision refs/pr/1",647        "hf cache verify my-dataset --repo-type dataset",648    ],649)650def verify(651    repo_id: RepoIdArg,652    repo_type: RepoTypeOpt = RepoTypeOpt.model,653    revision: RevisionOpt = None,654    cache_dir: Annotated[655        str | None,656        typer.Option(657            help="Cache directory to use when verifying files from cache (defaults to Hugging Face cache).",658        ),659    ] = None,660    local_dir: Annotated[661        str | None,662        typer.Option(663            help="If set, verify files under this directory instead of the cache.",664        ),665    ] = None,666    fail_on_missing_files: Annotated[667        bool,668        typer.Option(669            "--fail-on-missing-files",670            help="Fail if some files exist on the remote but are missing locally.",671        ),672    ] = False,673    fail_on_extra_files: Annotated[674        bool,675        typer.Option(676            "--fail-on-extra-files",677            help="Fail if some files exist locally but are not present on the remote revision.",678        ),679    ] = False,680    token: TokenOpt = None,681) -> None:682    """Verify checksums for a single repo revision from cache or a local directory.683 684    Examples:685      - Verify main revision in cache: `hf cache verify gpt2`686      - Verify specific revision: `hf cache verify gpt2 --revision refs/pr/1`687      - Verify dataset: `hf cache verify karpathy/fineweb-edu-100b-shuffle --repo-type dataset`688      - Verify local dir: `hf cache verify deepseek-ai/DeepSeek-OCR --local-dir /path/to/repo`689    """690 691    if local_dir is not None and cache_dir is not None:692        out.error("Cannot pass both --local-dir and --cache-dir. Use one or the other.")693        raise typer.Exit(code=2)694 695    api = get_hf_api(token=token)696 697    result = api.verify_repo_checksums(698        repo_id=repo_id,699        repo_type=repo_type.value if hasattr(repo_type, "value") else str(repo_type),700        revision=revision,701        local_dir=local_dir,702        cache_dir=cache_dir,703        token=token,704    )705 706    exit_code = 0707 708    if result.mismatches:709        details = "\n".join(710            f"  - {m['path']}: expected {m['expected']} ({m['algorithm']}), got {m['actual']}"711            for m in result.mismatches712        )713        out.text(f"❌ Checksum verification failed for the following file(s):\n{details}")714        exit_code = 1715 716    if result.missing_paths:717        if fail_on_missing_files:718            details = "\n".join(f"  - {p}" for p in result.missing_paths)719            out.text(f"❌ Missing files (present remotely, absent locally):\n{details}")720            exit_code = 1721        else:722            out.warning(723                f"{len(result.missing_paths)} remote file(s) are missing locally. "724                "Use --fail-on-missing-files for details."725            )726 727    if result.extra_paths:728        if fail_on_extra_files:729            details = "\n".join(f"  - {p}" for p in result.extra_paths)730            out.text(f"❌ Extra files (present locally, absent remotely):\n{details}")731            exit_code = 1732        else:733            out.warning(734                f"{len(result.extra_paths)} local file(s) do not exist on the remote repo. "735                "Use --fail-on-extra-files for details."736            )737 738    verified_location = result.verified_path739 740    if exit_code != 0:741        out.error(742            f"Verification failed for '{repo_id}' ({repo_type.value}) in {verified_location}.\n  Revision: {result.revision}"743        )744        raise typer.Exit(code=exit_code)745 746    out.result(747        f"Verified {result.checked_count} file(s) for {repo_type.value} '{repo_id}'. All checksums match.",748        repo_id=repo_id,749        repo_type=repo_type.value,750        checked=result.checked_count,751        path=str(verified_location),752    )753 
codekingpro/portable-devtools · Team Ai