Skip to content

Commit bcc8f30

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 5c0bedb commit bcc8f30

File tree

4 files changed

+275
-3
lines changed

4 files changed

+275
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4343
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
4444
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
4545

46-
4746
## [0.1.14] - 2026-03-09
4847

4948
### Added
@@ -63,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6362
- Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs)
6463
- 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement
6564
- Updated RFC, Extension User Guide, and Extension API Reference documentation
65+
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
6666

6767
## [0.1.13] - 2026-03-03
6868

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

src/specify_cli/extensions.py

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

9+
import fnmatch
910
import json
1011
import hashlib
1112
import os
@@ -14,7 +15,7 @@
1415
import shutil
1516
from dataclasses import dataclass
1617
from pathlib import Path
17-
from typing import Optional, Dict, List, Any
18+
from typing import Optional, Dict, List, Any, Callable, Set
1819
from datetime import datetime, timezone
1920
import re
2021

@@ -280,6 +281,56 @@ def __init__(self, project_root: Path):
280281
self.extensions_dir = project_root / ".specify" / "extensions"
281282
self.registry = ExtensionRegistry(self.extensions_dir)
282283

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

356-
shutil.copytree(source_dir, dest_dir)
407+
ignore_fn = self._load_extensionignore(source_dir)
408+
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
357409

358410
# Register commands with AI agents
359411
registered_commands = {}

tests/test_extensions.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,3 +1598,179 @@ def test_search_results_include_catalog_metadata(self, temp_dir):
15981598
assert len(results) == 1
15991599
assert results[0]["_catalog_name"] == "org"
16001600
assert results[0]["_install_allowed"] is True
1601+
1602+
1603+
class TestExtensionIgnore:
1604+
"""Test .extensionignore support during extension installation."""
1605+
1606+
def _make_extension(self, temp_dir, valid_manifest_data, extra_files=None, ignore_content=None):
1607+
"""Helper to create an extension directory with optional extra files and .extensionignore."""
1608+
import yaml
1609+
1610+
ext_dir = temp_dir / "ignored-ext"
1611+
ext_dir.mkdir()
1612+
1613+
# Write manifest
1614+
with open(ext_dir / "extension.yml", "w") as f:
1615+
yaml.dump(valid_manifest_data, f)
1616+
1617+
# Create commands directory with a command file
1618+
commands_dir = ext_dir / "commands"
1619+
commands_dir.mkdir()
1620+
(commands_dir / "hello.md").write_text(
1621+
"---\ndescription: \"Test hello command\"\n---\n\n# Hello\n\n$ARGUMENTS\n"
1622+
)
1623+
1624+
# Create any extra files/dirs
1625+
if extra_files:
1626+
for rel_path, content in extra_files.items():
1627+
p = ext_dir / rel_path
1628+
p.parent.mkdir(parents=True, exist_ok=True)
1629+
if content is None:
1630+
# Create directory
1631+
p.mkdir(parents=True, exist_ok=True)
1632+
else:
1633+
p.write_text(content)
1634+
1635+
# Write .extensionignore
1636+
if ignore_content is not None:
1637+
(ext_dir / ".extensionignore").write_text(ignore_content)
1638+
1639+
return ext_dir
1640+
1641+
def test_no_extensionignore(self, temp_dir, valid_manifest_data):
1642+
"""Without .extensionignore, all files are copied."""
1643+
ext_dir = self._make_extension(
1644+
temp_dir,
1645+
valid_manifest_data,
1646+
extra_files={"README.md": "# Hello", "tests/test_foo.py": "pass"},
1647+
)
1648+
1649+
proj_dir = temp_dir / "project"
1650+
proj_dir.mkdir()
1651+
(proj_dir / ".specify").mkdir()
1652+
1653+
manager = ExtensionManager(proj_dir)
1654+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1655+
1656+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1657+
assert (dest / "README.md").exists()
1658+
assert (dest / "tests" / "test_foo.py").exists()
1659+
1660+
def test_extensionignore_excludes_files(self, temp_dir, valid_manifest_data):
1661+
"""Files matching .extensionignore patterns are excluded."""
1662+
ext_dir = self._make_extension(
1663+
temp_dir,
1664+
valid_manifest_data,
1665+
extra_files={
1666+
"README.md": "# Hello",
1667+
"tests/test_foo.py": "pass",
1668+
"tests/test_bar.py": "pass",
1669+
".github/workflows/ci.yml": "on: push",
1670+
},
1671+
ignore_content="tests/\n.github/\n",
1672+
)
1673+
1674+
proj_dir = temp_dir / "project"
1675+
proj_dir.mkdir()
1676+
(proj_dir / ".specify").mkdir()
1677+
1678+
manager = ExtensionManager(proj_dir)
1679+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1680+
1681+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1682+
# Included
1683+
assert (dest / "README.md").exists()
1684+
assert (dest / "extension.yml").exists()
1685+
assert (dest / "commands" / "hello.md").exists()
1686+
# Excluded
1687+
assert not (dest / "tests").exists()
1688+
assert not (dest / ".github").exists()
1689+
1690+
def test_extensionignore_glob_patterns(self, temp_dir, valid_manifest_data):
1691+
"""Glob patterns like *.pyc are respected."""
1692+
ext_dir = self._make_extension(
1693+
temp_dir,
1694+
valid_manifest_data,
1695+
extra_files={
1696+
"README.md": "# Hello",
1697+
"helpers.pyc": b"\x00".decode("latin-1"),
1698+
"commands/cache.pyc": b"\x00".decode("latin-1"),
1699+
},
1700+
ignore_content="*.pyc\n",
1701+
)
1702+
1703+
proj_dir = temp_dir / "project"
1704+
proj_dir.mkdir()
1705+
(proj_dir / ".specify").mkdir()
1706+
1707+
manager = ExtensionManager(proj_dir)
1708+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1709+
1710+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1711+
assert (dest / "README.md").exists()
1712+
assert not (dest / "helpers.pyc").exists()
1713+
assert not (dest / "commands" / "cache.pyc").exists()
1714+
1715+
def test_extensionignore_comments_and_blanks(self, temp_dir, valid_manifest_data):
1716+
"""Comments and blank lines in .extensionignore are ignored."""
1717+
ext_dir = self._make_extension(
1718+
temp_dir,
1719+
valid_manifest_data,
1720+
extra_files={"README.md": "# Hello", "notes.txt": "some notes"},
1721+
ignore_content="# This is a comment\n\nnotes.txt\n\n# Another comment\n",
1722+
)
1723+
1724+
proj_dir = temp_dir / "project"
1725+
proj_dir.mkdir()
1726+
(proj_dir / ".specify").mkdir()
1727+
1728+
manager = ExtensionManager(proj_dir)
1729+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1730+
1731+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1732+
assert (dest / "README.md").exists()
1733+
assert not (dest / "notes.txt").exists()
1734+
1735+
def test_extensionignore_itself_excluded(self, temp_dir, valid_manifest_data):
1736+
""".extensionignore is never copied to the destination."""
1737+
ext_dir = self._make_extension(
1738+
temp_dir,
1739+
valid_manifest_data,
1740+
ignore_content="# nothing special here\n",
1741+
)
1742+
1743+
proj_dir = temp_dir / "project"
1744+
proj_dir.mkdir()
1745+
(proj_dir / ".specify").mkdir()
1746+
1747+
manager = ExtensionManager(proj_dir)
1748+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1749+
1750+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1751+
assert (dest / "extension.yml").exists()
1752+
assert not (dest / ".extensionignore").exists()
1753+
1754+
def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data):
1755+
"""Patterns matching relative paths work correctly."""
1756+
ext_dir = self._make_extension(
1757+
temp_dir,
1758+
valid_manifest_data,
1759+
extra_files={
1760+
"docs/guide.md": "# Guide",
1761+
"docs/internal/draft.md": "draft",
1762+
"README.md": "# Hello",
1763+
},
1764+
ignore_content="docs/internal/draft.md\n",
1765+
)
1766+
1767+
proj_dir = temp_dir / "project"
1768+
proj_dir.mkdir()
1769+
(proj_dir / ".specify").mkdir()
1770+
1771+
manager = ExtensionManager(proj_dir)
1772+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1773+
1774+
dest = proj_dir / ".specify" / "extensions" / "test-ext"
1775+
assert (dest / "docs" / "guide.md").exists()
1776+
assert not (dest / "docs" / "internal" / "draft.md").exists()

0 commit comments

Comments
 (0)