Skip to content

Commit 46501fa

Browse files
authored
Add .comfyignore support to node packaging (#321)
1 parent 07ef636 commit 46501fa

File tree

7 files changed

+241
-29
lines changed

7 files changed

+241
-29
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,4 @@ requirements.compiled
6161
override.txt
6262
.coverage
6363
coverage.xml
64+

DEV_README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,32 @@ either run `pip install -e .` again (which will reinstall), or manually
5858
uninstall `pip uninstall comfy-cli` and reinstall, or even cleaning your conda
5959
env and reinstalling the package (`pip install -e .`)
6060

61+
## Packaging custom nodes with `.comfyignore`
62+
63+
`comfy node pack` and `comfy node publish` now read an optional `.comfyignore`
64+
file in the project root. The syntax matches `.gitignore` (implemented with
65+
`PathSpec`'s `gitwildmatch` rules), so you can reuse familiar patterns to keep
66+
development-only artifacts out of your published archive.
67+
68+
- Patterns are evaluated against paths relative to the directory you run the
69+
command from (usually the repo root).
70+
- Files required by the pack command itself (e.g. `__init__.py`, `web/*`) are
71+
still forced into the archive even if they match an ignore pattern.
72+
- If no `.comfyignore` is present the command falls back to the original
73+
behavior and zips every git-tracked file.
74+
75+
Example `.comfyignore`:
76+
77+
```gitignore
78+
docs/
79+
frontend/
80+
tests/
81+
*.psd
82+
```
83+
84+
Commit the file alongside your node so teammates and CI pipelines produce the
85+
same trimmed package.
86+
6187
## Adding a new command
6288

6389
- Register it under `comfy_cli/cmdline.py`

comfy_cli/command/custom_nodes/command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -942,7 +942,7 @@ def registry_install(
942942

943943
@app.command(
944944
"pack",
945-
help="Pack the current node into a zip file. Ignorining .gitignore files.",
945+
help="Pack the current node into a zip file using git-tracked files and honoring .comfyignore patterns.",
946946
)
947947
@tracking.track_command("pack")
948948
def pack():

comfy_cli/file_utils.py

Lines changed: 108 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import json
22
import os
33
import pathlib
4+
import subprocess
45
import zipfile
5-
from typing import Optional
6+
from typing import Optional, Union
67

78
import httpx
89
import requests
10+
from pathspec import PathSpec
911

1012
from comfy_cli import constants, ui
1113

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

8890

91+
def _load_comfyignore_spec(ignore_filename: str = ".comfyignore") -> Optional[PathSpec]:
92+
if not os.path.exists(ignore_filename):
93+
return None
94+
try:
95+
with open(ignore_filename, encoding="utf-8") as ignore_file:
96+
patterns = [line.strip() for line in ignore_file if line.strip() and not line.lstrip().startswith("#")]
97+
except OSError:
98+
return None
99+
100+
if not patterns:
101+
return None
102+
103+
return PathSpec.from_lines("gitwildmatch", patterns)
104+
105+
106+
def list_git_tracked_files(base_path: Union[str, os.PathLike] = ".") -> list[str]:
107+
try:
108+
result = subprocess.check_output(
109+
["git", "-C", os.fspath(base_path), "ls-files"],
110+
text=True,
111+
)
112+
except (subprocess.SubprocessError, FileNotFoundError):
113+
return []
114+
115+
return [line for line in result.splitlines() if line.strip()]
116+
117+
118+
def _normalize_path(path: str) -> str:
119+
rel_path = os.path.relpath(path, start=".")
120+
if rel_path == ".":
121+
return ""
122+
return rel_path.replace("\\", "/")
123+
124+
125+
def _is_force_included(rel_path: str, include_prefixes: list[str]) -> bool:
126+
return any(rel_path == prefix or rel_path.startswith(prefix + "/") for prefix in include_prefixes if prefix)
127+
128+
89129
def zip_files(zip_filename, includes=None):
90-
"""
91-
Zip all files in the current directory that are tracked by git,
92-
plus any additional directories specified in includes.
93-
"""
130+
"""Zip git-tracked files respecting optional .comfyignore patterns."""
94131
includes = includes or []
95-
included_paths = set()
96-
git_files = []
132+
include_prefixes: list[str] = [_normalize_path(os.path.normpath(include.lstrip("/"))) for include in includes]
97133

98-
try:
99-
import subprocess
134+
included_paths: set[str] = set()
135+
git_files: list[str] = []
100136

101-
git_files = subprocess.check_output(["git", "ls-files"], text=True).splitlines()
102-
except (subprocess.SubprocessError, FileNotFoundError):
137+
ignore_spec = _load_comfyignore_spec()
138+
139+
def should_ignore(rel_path: str) -> bool:
140+
if not ignore_spec:
141+
return False
142+
if _is_force_included(rel_path, include_prefixes):
143+
return False
144+
return ignore_spec.match_file(rel_path)
145+
146+
zip_target = os.fspath(zip_filename)
147+
zip_abs_path = os.path.abspath(zip_target)
148+
zip_basename = os.path.basename(zip_abs_path)
149+
150+
git_files = list_git_tracked_files(".")
151+
if not git_files:
103152
print("Warning: Not in a git repository or git not installed. Zipping all files.")
104153

105-
# Zip only git-tracked files
106-
with zipfile.ZipFile(zip_filename, "w", zipfile.ZIP_DEFLATED) as zipf:
154+
with zipfile.ZipFile(zip_target, "w", zipfile.ZIP_DEFLATED) as zipf:
107155
if git_files:
108156
for file_path in git_files:
109-
if zip_filename in file_path:
157+
if file_path == zip_basename:
158+
continue
159+
160+
rel_path = _normalize_path(file_path)
161+
if should_ignore(rel_path):
162+
continue
163+
164+
actual_path = os.path.normpath(file_path)
165+
if os.path.abspath(actual_path) == zip_abs_path:
110166
continue
111-
if os.path.exists(file_path):
112-
zipf.write(file_path)
113-
included_paths.add(file_path)
167+
if os.path.exists(actual_path):
168+
arcname = rel_path or os.path.basename(actual_path)
169+
zipf.write(actual_path, arcname)
170+
included_paths.add(rel_path)
114171
else:
115172
print(f"File not found. Not including in zip: {file_path}")
116173
else:
117174
for root, dirs, files in os.walk("."):
118175
if ".git" in dirs:
119176
dirs.remove(".git")
177+
dirs[:] = [d for d in dirs if not should_ignore(_normalize_path(os.path.join(root, d)))]
120178
for file in files:
121179
file_path = os.path.join(root, file)
122-
# Skip zipping the zip file itself
123-
if zip_filename in file_path:
180+
rel_path = _normalize_path(file_path)
181+
if (
182+
os.path.abspath(file_path) == zip_abs_path
183+
or rel_path in included_paths
184+
or should_ignore(rel_path)
185+
):
124186
continue
125-
relative_path = os.path.relpath(file_path, start=".")
126-
zipf.write(file_path, relative_path)
127-
included_paths.add(file_path)
187+
arcname = rel_path or file_path
188+
zipf.write(file_path, arcname)
189+
included_paths.add(rel_path)
128190

129191
for include_dir in includes:
130-
include_dir = include_dir.lstrip("/")
192+
include_dir = os.path.normpath(include_dir.lstrip("/"))
193+
rel_include = _normalize_path(include_dir)
194+
195+
if os.path.isfile(include_dir):
196+
if not should_ignore(rel_include) and rel_include not in included_paths:
197+
arcname = rel_include or include_dir
198+
zipf.write(include_dir, arcname)
199+
included_paths.add(rel_include)
200+
continue
201+
131202
if not os.path.exists(include_dir):
132203
print(f"Warning: Included directory '{include_dir}' does not exist, creating empty directory")
133-
zipf.writestr(f"{include_dir}/", "")
204+
arcname = rel_include or include_dir
205+
if not arcname.endswith("/"):
206+
arcname = arcname + "/"
207+
zipf.writestr(arcname, "")
134208
continue
135209

136210
for root, dirs, files in os.walk(include_dir):
211+
dirs[:] = [d for d in dirs if not should_ignore(_normalize_path(os.path.join(root, d)))]
137212
for file in files:
138213
file_path = os.path.join(root, file)
139-
if zip_filename in file_path or file_path in included_paths:
214+
rel_path = _normalize_path(file_path)
215+
if (
216+
os.path.abspath(file_path) == zip_abs_path
217+
or rel_path in included_paths
218+
or should_ignore(rel_path)
219+
):
140220
continue
141-
relative_path = os.path.relpath(file_path, start=".")
142-
zipf.write(file_path, relative_path)
143-
included_paths.add(file_path)
221+
arcname = rel_path or file_path
222+
zipf.write(file_path, arcname)
223+
included_paths.add(rel_path)
144224

145225

146226
def upload_file_to_signed_url(signed_url: str, file_path: str):

tests/comfy_cli/test_file_utils.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import os
2+
import zipfile
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from comfy_cli import file_utils
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def restore_cwd():
12+
original_cwd = Path.cwd()
13+
try:
14+
yield
15+
finally:
16+
os.chdir(original_cwd)
17+
18+
19+
def test_zip_files_respects_comfyignore(tmp_path, monkeypatch):
20+
project_dir = tmp_path
21+
(project_dir / "keep.txt").write_text("keep", encoding="utf-8")
22+
(project_dir / "ignore.log").write_text("ignore", encoding="utf-8")
23+
ignored_dir = project_dir / "ignored_dir"
24+
ignored_dir.mkdir()
25+
(ignored_dir / "nested.txt").write_text("nested", encoding="utf-8")
26+
27+
(project_dir / ".comfyignore").write_text("*.log\nignored_dir/\n", encoding="utf-8")
28+
29+
zip_path = project_dir / "node.zip"
30+
31+
monkeypatch.chdir(project_dir)
32+
monkeypatch.setattr(
33+
file_utils,
34+
"list_git_tracked_files",
35+
lambda base_path=".": [
36+
"keep.txt",
37+
"ignore.log",
38+
"ignored_dir/nested.txt",
39+
],
40+
)
41+
42+
file_utils.zip_files(str(zip_path))
43+
44+
with zipfile.ZipFile(zip_path, "r") as zf:
45+
names = set(zf.namelist())
46+
47+
assert "keep.txt" in names
48+
assert "ignore.log" not in names
49+
assert not any(name.startswith("ignored_dir/") for name in names)
50+
51+
52+
def test_zip_files_force_include_overrides_ignore(tmp_path, monkeypatch):
53+
project_dir = tmp_path
54+
include_dir = project_dir / "include_me"
55+
include_dir.mkdir()
56+
(include_dir / "data.json").write_text("{}", encoding="utf-8")
57+
58+
(project_dir / "other.txt").write_text("ok", encoding="utf-8")
59+
(project_dir / ".comfyignore").write_text("include_me/\n", encoding="utf-8")
60+
61+
zip_path = project_dir / "node.zip"
62+
63+
monkeypatch.chdir(project_dir)
64+
monkeypatch.setattr(
65+
file_utils,
66+
"list_git_tracked_files",
67+
lambda base_path=".": [
68+
"other.txt",
69+
"include_me/data.json",
70+
],
71+
)
72+
73+
file_utils.zip_files(str(zip_path), includes=["include_me"])
74+
75+
with zipfile.ZipFile(zip_path, "r") as zf:
76+
names = set(zf.namelist())
77+
78+
assert "include_me/data.json" in names
79+
assert "other.txt" in names
80+
81+
82+
def test_zip_files_without_git_falls_back_to_walk(tmp_path, monkeypatch):
83+
project_dir = tmp_path
84+
(project_dir / "file.txt").write_text("data", encoding="utf-8")
85+
zip_path = project_dir / "node.zip"
86+
87+
monkeypatch.chdir(project_dir)
88+
monkeypatch.setattr(file_utils, "list_git_tracked_files", lambda base_path=".": [])
89+
90+
file_utils.zip_files(str(zip_path))
91+
92+
with zipfile.ZipFile(zip_path, "r") as zf:
93+
names = set(zf.namelist())
94+
95+
assert "file.txt" in names
96+
assert "node.zip" not in names

tests/uv/test_uv.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,13 @@ def test_compile(mock_prompt_select):
6767
[line for line in known.readlines() if not line.strip().startswith("#")],
6868
[line for line in test.readlines() if not line.strip().startswith("#")],
6969
]
70+
71+
optionalPrefixes = ("colorama==",)
72+
73+
def _filter_optional(lines: list[str]) -> list[str]:
74+
# drop platform-specific extras (Windows pulls in colorama via tqdm)
75+
return [line for line in lines if not any(line.strip().startswith(prefix) for prefix in optionalPrefixes)]
76+
77+
knownLines, testLines = [_filter_optional(lines) for lines in (knownLines, testLines)]
78+
7079
assert knownLines == testLines

0 commit comments

Comments
 (0)