Skip to content

Commit 0a00c97

Browse files
committed
feat(extensions): support .extensionignore to exclude files during install
Add .extensionignore support so extension authors can exclude files and folders from being copied when users run 'specify extension add'. The file uses glob-style patterns (one per line), supports comments (#), blank lines, trailing-slash directory patterns, and relative path matching. The .extensionignore file itself is always excluded from the copy. - Add _load_extensionignore() to ExtensionManager - Integrate ignore function into shutil.copytree in install_from_directory - Document .extensionignore in EXTENSION-DEVELOPMENT-GUIDE.md - Add 6 tests covering all pattern matching scenarios - Bump version to 0.1.14
1 parent 71e6b4d commit 0a00c97

File tree

5 files changed

+281
-3
lines changed

5 files changed

+281
-3
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ Recent changes to the Specify CLI and templates are documented here.
77
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

10+
## [0.1.14] - 2026-03-09
11+
12+
### Added
13+
14+
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#TBD)
15+
1016
## [0.1.13] - 2026-03-03
1117

1218
### Changed

extensions/EXTENSION-DEVELOPMENT-GUIDE.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,50 @@ echo "$config"
332332

333333
---
334334

335+
## Excluding Files with `.extensionignore`
336+
337+
Extension authors can create a `.extensionignore` file in the extension root to exclude files and folders from being copied when a user installs the extension with `specify extension add`. This is useful for keeping development-only files (tests, CI configs, docs source, etc.) out of the installed copy.
338+
339+
### Format
340+
341+
The file uses glob-style patterns, one per line:
342+
343+
- Blank lines are ignored
344+
- Lines starting with `#` are comments
345+
- Patterns are matched against both the file/folder name and its path relative to the extension root
346+
- The `.extensionignore` file itself is always excluded automatically
347+
348+
### Example
349+
350+
```gitignore
351+
# .extensionignore
352+
353+
# Development files
354+
tests/
355+
.github/
356+
.gitignore
357+
358+
# Build artifacts
359+
__pycache__/
360+
*.pyc
361+
dist/
362+
363+
# Documentation source (keep only the built README)
364+
docs/
365+
CONTRIBUTING.md
366+
```
367+
368+
### Pattern Matching
369+
370+
| Pattern | Matches |
371+
|---------|---------|
372+
| `*.pyc` | Any `.pyc` file in any directory |
373+
| `tests/` | The `tests` directory (and all its contents) |
374+
| `docs/*.draft.md` | Draft markdown files inside `docs/` |
375+
| `.github` | The `.github` directory at any level |
376+
377+
---
378+
335379
## Validation Rules
336380

337381
### Extension ID

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "specify-cli"
3-
version = "0.1.13"
3+
version = "0.1.14"
44
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
55
requires-python = ">=3.11"
66
dependencies = [

src/specify_cli/extensions.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
without bloating the core framework.
77
"""
88

9+
import fnmatch
910
import json
1011
import hashlib
1112
import tempfile
1213
import zipfile
1314
import shutil
1415
from pathlib import Path
15-
from typing import Optional, Dict, List, Any
16+
from typing import Optional, Dict, List, Any, Callable, Set
1617
from datetime import datetime, timezone
1718
import re
1819

@@ -268,6 +269,56 @@ def __init__(self, project_root: Path):
268269
self.extensions_dir = project_root / ".specify" / "extensions"
269270
self.registry = ExtensionRegistry(self.extensions_dir)
270271

272+
@staticmethod
273+
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
274+
"""Load .extensionignore and return an ignore function for shutil.copytree.
275+
276+
The .extensionignore file uses glob-style patterns (one per line).
277+
Lines starting with '#' are comments. Blank lines are ignored.
278+
The .extensionignore file itself is always excluded.
279+
280+
Args:
281+
source_dir: Path to the extension source directory
282+
283+
Returns:
284+
An ignore function compatible with shutil.copytree, or None
285+
if no .extensionignore file exists.
286+
"""
287+
ignore_file = source_dir / ".extensionignore"
288+
if not ignore_file.exists():
289+
return None
290+
291+
patterns: List[str] = []
292+
for line in ignore_file.read_text().splitlines():
293+
stripped = line.strip()
294+
if stripped and not stripped.startswith("#"):
295+
patterns.append(stripped)
296+
297+
# Always ignore the .extensionignore file itself
298+
patterns.append(".extensionignore")
299+
300+
def _ignore(directory: str, entries: List[str]) -> Set[str]:
301+
ignored: Set[str] = set()
302+
rel_dir = Path(directory).relative_to(source_dir)
303+
for entry in entries:
304+
rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry
305+
# Normalise to forward slashes for consistent matching
306+
rel_path_fwd = rel_path.replace("\\", "/")
307+
for pattern in patterns:
308+
# Strip trailing slash so "tests/" matches directory name "tests"
309+
pat = pattern.rstrip("/")
310+
# Match against the entry name itself
311+
if fnmatch.fnmatch(entry, pat):
312+
ignored.add(entry)
313+
break
314+
# Match against the relative path from the source root
315+
if fnmatch.fnmatch(rel_path_fwd, pat):
316+
ignored.add(entry)
317+
break
318+
return ignored
319+
320+
return _ignore
321+
271322
def check_compatibility(
272323
self,
273324
manifest: ExtensionManifest,
@@ -341,7 +392,8 @@ def install_from_directory(
341392
if dest_dir.exists():
342393
shutil.rmtree(dest_dir)
343394

344-
shutil.copytree(source_dir, dest_dir)
395+
ignore_fn = self._load_extensionignore(source_dir)
396+
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
345397

346398
# Register commands with AI agents
347399
registered_commands = {}

tests/test_extensions.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,3 +1133,179 @@ def test_clear_cache(self, temp_dir):
11331133

11341134
assert not catalog.cache_file.exists()
11351135
assert not catalog.cache_metadata_file.exists()
1136+
1137+
1138+
class TestExtensionIgnore:
1139+
"""Test .extensionignore support during extension installation."""
1140+
1141+
def _make_extension(self, temp_dir, valid_manifest_data, extra_files=None, ignore_content=None):
1142+
"""Helper to create an extension directory with optional extra files and .extensionignore."""
1143+
import yaml
1144+
1145+
ext_dir = temp_dir / "ignored-ext"
1146+
ext_dir.mkdir()
1147+
1148+
# Write manifest
1149+
with open(ext_dir / "extension.yml", "w") as f:
1150+
yaml.dump(valid_manifest_data, f)
1151+
1152+
# Create commands directory with a command file
1153+
commands_dir = ext_dir / "commands"
1154+
commands_dir.mkdir()
1155+
(commands_dir / "hello.md").write_text(
1156+
"---\ndescription: \"Test hello command\"\n---\n\n# Hello\n\n$ARGUMENTS\n"
1157+
)
1158+
1159+
# Create any extra files/dirs
1160+
if extra_files:
1161+
for rel_path, content in extra_files.items():
1162+
p = ext_dir / rel_path
1163+
p.parent.mkdir(parents=True, exist_ok=True)
1164+
if content is None:
1165+
# Create directory
1166+
p.mkdir(parents=True, exist_ok=True)
1167+
else:
1168+
p.write_text(content)
1169+
1170+
# Write .extensionignore
1171+
if ignore_content is not None:
1172+
(ext_dir / ".extensionignore").write_text(ignore_content)
1173+
1174+
return ext_dir
1175+
1176+
def test_no_extensionignore(self, temp_dir, valid_manifest_data):
1177+
"""Without .extensionignore, all files are copied."""
1178+
ext_dir = self._make_extension(
1179+
temp_dir,
1180+
valid_manifest_data,
1181+
extra_files={"README.md": "# Hello", "tests/test_foo.py": "pass"},
1182+
)
1183+
1184+
proj_dir = temp_dir / "project"
1185+
proj_dir.mkdir()
1186+
(proj_dir / ".specify").mkdir()
1187+
1188+
manager = ExtensionManager(proj_dir)
1189+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1190+
1191+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1192+
assert (dest / "README.md").exists()
1193+
assert (dest / "tests" / "test_foo.py").exists()
1194+
1195+
def test_extensionignore_excludes_files(self, temp_dir, valid_manifest_data):
1196+
"""Files matching .extensionignore patterns are excluded."""
1197+
ext_dir = self._make_extension(
1198+
temp_dir,
1199+
valid_manifest_data,
1200+
extra_files={
1201+
"README.md": "# Hello",
1202+
"tests/test_foo.py": "pass",
1203+
"tests/test_bar.py": "pass",
1204+
".github/workflows/ci.yml": "on: push",
1205+
},
1206+
ignore_content="tests/\n.github/\n",
1207+
)
1208+
1209+
proj_dir = temp_dir / "project"
1210+
proj_dir.mkdir()
1211+
(proj_dir / ".specify").mkdir()
1212+
1213+
manager = ExtensionManager(proj_dir)
1214+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1215+
1216+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1217+
# Included
1218+
assert (dest / "README.md").exists()
1219+
assert (dest / "extension.yml").exists()
1220+
assert (dest / "commands" / "hello.md").exists()
1221+
# Excluded
1222+
assert not (dest / "tests").exists()
1223+
assert not (dest / ".github").exists()
1224+
1225+
def test_extensionignore_glob_patterns(self, temp_dir, valid_manifest_data):
1226+
"""Glob patterns like *.pyc are respected."""
1227+
ext_dir = self._make_extension(
1228+
temp_dir,
1229+
valid_manifest_data,
1230+
extra_files={
1231+
"README.md": "# Hello",
1232+
"helpers.pyc": b"\x00".decode("latin-1"),
1233+
"commands/cache.pyc": b"\x00".decode("latin-1"),
1234+
},
1235+
ignore_content="*.pyc\n",
1236+
)
1237+
1238+
proj_dir = temp_dir / "project"
1239+
proj_dir.mkdir()
1240+
(proj_dir / ".specify").mkdir()
1241+
1242+
manager = ExtensionManager(proj_dir)
1243+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1244+
1245+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1246+
assert (dest / "README.md").exists()
1247+
assert not (dest / "helpers.pyc").exists()
1248+
assert not (dest / "commands" / "cache.pyc").exists()
1249+
1250+
def test_extensionignore_comments_and_blanks(self, temp_dir, valid_manifest_data):
1251+
"""Comments and blank lines in .extensionignore are ignored."""
1252+
ext_dir = self._make_extension(
1253+
temp_dir,
1254+
valid_manifest_data,
1255+
extra_files={"README.md": "# Hello", "notes.txt": "some notes"},
1256+
ignore_content="# This is a comment\n\nnotes.txt\n\n# Another comment\n",
1257+
)
1258+
1259+
proj_dir = temp_dir / "project"
1260+
proj_dir.mkdir()
1261+
(proj_dir / ".specify").mkdir()
1262+
1263+
manager = ExtensionManager(proj_dir)
1264+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1265+
1266+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1267+
assert (dest / "README.md").exists()
1268+
assert not (dest / "notes.txt").exists()
1269+
1270+
def test_extensionignore_itself_excluded(self, temp_dir, valid_manifest_data):
1271+
""".extensionignore is never copied to the destination."""
1272+
ext_dir = self._make_extension(
1273+
temp_dir,
1274+
valid_manifest_data,
1275+
ignore_content="# nothing special here\n",
1276+
)
1277+
1278+
proj_dir = temp_dir / "project"
1279+
proj_dir.mkdir()
1280+
(proj_dir / ".specify").mkdir()
1281+
1282+
manager = ExtensionManager(proj_dir)
1283+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1284+
1285+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1286+
assert (dest / "extension.yml").exists()
1287+
assert not (dest / ".extensionignore").exists()
1288+
1289+
def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data):
1290+
"""Patterns matching relative paths work correctly."""
1291+
ext_dir = self._make_extension(
1292+
temp_dir,
1293+
valid_manifest_data,
1294+
extra_files={
1295+
"docs/guide.md": "# Guide",
1296+
"docs/internal/draft.md": "draft",
1297+
"README.md": "# Hello",
1298+
},
1299+
ignore_content="docs/internal/draft.md\n",
1300+
)
1301+
1302+
proj_dir = temp_dir / "project"
1303+
proj_dir.mkdir()
1304+
(proj_dir / ".specify").mkdir()
1305+
1306+
manager = ExtensionManager(proj_dir)
1307+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1308+
1309+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1310+
assert (dest / "docs" / "guide.md").exists()
1311+
assert not (dest / "docs" / "internal" / "draft.md").exists()

0 commit comments

Comments
 (0)