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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,4 @@ requirements.compiled
override.txt
.coverage
coverage.xml

26 changes: 26 additions & 0 deletions DEV_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,32 @@ either run `pip install -e .` again (which will reinstall), or manually
uninstall `pip uninstall comfy-cli` and reinstall, or even cleaning your conda
env and reinstalling the package (`pip install -e .`)

## Packaging custom nodes with `.comfyignore`

`comfy node pack` and `comfy node publish` now read an optional `.comfyignore`
file in the project root. The syntax matches `.gitignore` (implemented with
`PathSpec`'s `gitwildmatch` rules), so you can reuse familiar patterns to keep
development-only artifacts out of your published archive.

- Patterns are evaluated against paths relative to the directory you run the
command from (usually the repo root).
- Files required by the pack command itself (e.g. `__init__.py`, `web/*`) are
still forced into the archive even if they match an ignore pattern.
- If no `.comfyignore` is present the command falls back to the original
behavior and zips every git-tracked file.

Example `.comfyignore`:

```gitignore
docs/
frontend/
tests/
*.psd
```

Commit the file alongside your node so teammates and CI pipelines produce the
same trimmed package.

## Adding a new command

- Register it under `comfy_cli/cmdline.py`
Expand Down
2 changes: 1 addition & 1 deletion comfy_cli/command/custom_nodes/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,7 @@ def registry_install(

@app.command(
"pack",
help="Pack the current node into a zip file. Ignorining .gitignore files.",
help="Pack the current node into a zip file using git-tracked files and honoring .comfyignore patterns.",
)
@tracking.track_command("pack")
def pack():
Expand Down
136 changes: 108 additions & 28 deletions comfy_cli/file_utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import json
import os
import pathlib
import subprocess
import zipfile
from typing import Optional
from typing import Optional, Union

import httpx
import requests
from pathspec import PathSpec

from comfy_cli import constants, ui

Expand Down Expand Up @@ -86,61 +88,139 @@ def download_file(url: str, local_filepath: pathlib.Path, headers: Optional[dict
raise DownloadException(f"Failed to download file.\n{status_reason}")


def _load_comfyignore_spec(ignore_filename: str = ".comfyignore") -> Optional[PathSpec]:
if not os.path.exists(ignore_filename):
return None
try:
with open(ignore_filename, encoding="utf-8") as ignore_file:
patterns = [line.strip() for line in ignore_file if line.strip() and not line.lstrip().startswith("#")]
except OSError:
return None

if not patterns:
return None

return PathSpec.from_lines("gitwildmatch", patterns)


def list_git_tracked_files(base_path: Union[str, os.PathLike] = ".") -> list[str]:
try:
result = subprocess.check_output(
["git", "-C", os.fspath(base_path), "ls-files"],
text=True,
)
except (subprocess.SubprocessError, FileNotFoundError):
return []

return [line for line in result.splitlines() if line.strip()]


def _normalize_path(path: str) -> str:
rel_path = os.path.relpath(path, start=".")
if rel_path == ".":
return ""
return rel_path.replace("\\", "/")


def _is_force_included(rel_path: str, include_prefixes: list[str]) -> bool:
return any(rel_path == prefix or rel_path.startswith(prefix + "/") for prefix in include_prefixes if prefix)


def zip_files(zip_filename, includes=None):
"""
Zip all files in the current directory that are tracked by git,
plus any additional directories specified in includes.
"""
"""Zip git-tracked files respecting optional .comfyignore patterns."""
includes = includes or []
included_paths = set()
git_files = []
include_prefixes: list[str] = [_normalize_path(os.path.normpath(include.lstrip("/"))) for include in includes]

try:
import subprocess
included_paths: set[str] = set()
git_files: list[str] = []

git_files = subprocess.check_output(["git", "ls-files"], text=True).splitlines()
except (subprocess.SubprocessError, FileNotFoundError):
ignore_spec = _load_comfyignore_spec()

def should_ignore(rel_path: str) -> bool:
if not ignore_spec:
return False
if _is_force_included(rel_path, include_prefixes):
return False
return ignore_spec.match_file(rel_path)

zip_target = os.fspath(zip_filename)
zip_abs_path = os.path.abspath(zip_target)
zip_basename = os.path.basename(zip_abs_path)

git_files = list_git_tracked_files(".")
if not git_files:
print("Warning: Not in a git repository or git not installed. Zipping all files.")

# Zip only git-tracked files
with zipfile.ZipFile(zip_filename, "w", zipfile.ZIP_DEFLATED) as zipf:
with zipfile.ZipFile(zip_target, "w", zipfile.ZIP_DEFLATED) as zipf:
if git_files:
for file_path in git_files:
if zip_filename in file_path:
if file_path == zip_basename:
continue

rel_path = _normalize_path(file_path)
if should_ignore(rel_path):
continue

actual_path = os.path.normpath(file_path)
if os.path.abspath(actual_path) == zip_abs_path:
continue
if os.path.exists(file_path):
zipf.write(file_path)
included_paths.add(file_path)
if os.path.exists(actual_path):
arcname = rel_path or os.path.basename(actual_path)
zipf.write(actual_path, arcname)
included_paths.add(rel_path)
else:
print(f"File not found. Not including in zip: {file_path}")
else:
for root, dirs, files in os.walk("."):
if ".git" in dirs:
dirs.remove(".git")
dirs[:] = [d for d in dirs if not should_ignore(_normalize_path(os.path.join(root, d)))]
for file in files:
file_path = os.path.join(root, file)
# Skip zipping the zip file itself
if zip_filename in file_path:
rel_path = _normalize_path(file_path)
if (
os.path.abspath(file_path) == zip_abs_path
or rel_path in included_paths
or should_ignore(rel_path)
):
continue
relative_path = os.path.relpath(file_path, start=".")
zipf.write(file_path, relative_path)
included_paths.add(file_path)
arcname = rel_path or file_path
zipf.write(file_path, arcname)
included_paths.add(rel_path)

for include_dir in includes:
include_dir = include_dir.lstrip("/")
include_dir = os.path.normpath(include_dir.lstrip("/"))
rel_include = _normalize_path(include_dir)

if os.path.isfile(include_dir):
if not should_ignore(rel_include) and rel_include not in included_paths:
arcname = rel_include or include_dir
zipf.write(include_dir, arcname)
included_paths.add(rel_include)
continue

if not os.path.exists(include_dir):
print(f"Warning: Included directory '{include_dir}' does not exist, creating empty directory")
zipf.writestr(f"{include_dir}/", "")
arcname = rel_include or include_dir
if not arcname.endswith("/"):
arcname = arcname + "/"
zipf.writestr(arcname, "")
continue

for root, dirs, files in os.walk(include_dir):
dirs[:] = [d for d in dirs if not should_ignore(_normalize_path(os.path.join(root, d)))]
for file in files:
file_path = os.path.join(root, file)
if zip_filename in file_path or file_path in included_paths:
rel_path = _normalize_path(file_path)
if (
os.path.abspath(file_path) == zip_abs_path
or rel_path in included_paths
or should_ignore(rel_path)
):
continue
relative_path = os.path.relpath(file_path, start=".")
zipf.write(file_path, relative_path)
included_paths.add(file_path)
arcname = rel_path or file_path
zipf.write(file_path, arcname)
included_paths.add(rel_path)


def upload_file_to_signed_url(signed_url: str, file_path: str):
Expand Down
96 changes: 96 additions & 0 deletions tests/comfy_cli/test_file_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import os
import zipfile
from pathlib import Path

import pytest

from comfy_cli import file_utils


@pytest.fixture(autouse=True)
def restore_cwd():
original_cwd = Path.cwd()
try:
yield
finally:
os.chdir(original_cwd)


def test_zip_files_respects_comfyignore(tmp_path, monkeypatch):
project_dir = tmp_path
(project_dir / "keep.txt").write_text("keep", encoding="utf-8")
(project_dir / "ignore.log").write_text("ignore", encoding="utf-8")
ignored_dir = project_dir / "ignored_dir"
ignored_dir.mkdir()
(ignored_dir / "nested.txt").write_text("nested", encoding="utf-8")

(project_dir / ".comfyignore").write_text("*.log\nignored_dir/\n", encoding="utf-8")

zip_path = project_dir / "node.zip"

monkeypatch.chdir(project_dir)
monkeypatch.setattr(
file_utils,
"list_git_tracked_files",
lambda base_path=".": [
"keep.txt",
"ignore.log",
"ignored_dir/nested.txt",
],
)

file_utils.zip_files(str(zip_path))

with zipfile.ZipFile(zip_path, "r") as zf:
names = set(zf.namelist())

assert "keep.txt" in names
assert "ignore.log" not in names
assert not any(name.startswith("ignored_dir/") for name in names)


def test_zip_files_force_include_overrides_ignore(tmp_path, monkeypatch):
project_dir = tmp_path
include_dir = project_dir / "include_me"
include_dir.mkdir()
(include_dir / "data.json").write_text("{}", encoding="utf-8")

(project_dir / "other.txt").write_text("ok", encoding="utf-8")
(project_dir / ".comfyignore").write_text("include_me/\n", encoding="utf-8")

zip_path = project_dir / "node.zip"

monkeypatch.chdir(project_dir)
monkeypatch.setattr(
file_utils,
"list_git_tracked_files",
lambda base_path=".": [
"other.txt",
"include_me/data.json",
],
)

file_utils.zip_files(str(zip_path), includes=["include_me"])

with zipfile.ZipFile(zip_path, "r") as zf:
names = set(zf.namelist())

assert "include_me/data.json" in names
assert "other.txt" in names


def test_zip_files_without_git_falls_back_to_walk(tmp_path, monkeypatch):
project_dir = tmp_path
(project_dir / "file.txt").write_text("data", encoding="utf-8")
zip_path = project_dir / "node.zip"

monkeypatch.chdir(project_dir)
monkeypatch.setattr(file_utils, "list_git_tracked_files", lambda base_path=".": [])

file_utils.zip_files(str(zip_path))

with zipfile.ZipFile(zip_path, "r") as zf:
names = set(zf.namelist())

assert "file.txt" in names
assert "node.zip" not in names
File renamed without changes.
9 changes: 9 additions & 0 deletions tests/uv/test_uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,13 @@ def test_compile(mock_prompt_select):
[line for line in known.readlines() if not line.strip().startswith("#")],
[line for line in test.readlines() if not line.strip().startswith("#")],
]

optionalPrefixes = ("colorama==",)

def _filter_optional(lines: list[str]) -> list[str]:
# drop platform-specific extras (Windows pulls in colorama via tqdm)
return [line for line in lines if not any(line.strip().startswith(prefix) for prefix in optionalPrefixes)]

knownLines, testLines = [_filter_optional(lines) for lines in (knownLines, testLines)]

assert knownLines == testLines
Loading