Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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 comfy_cli/tests/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