Skip to content

Commit 2632a0f

Browse files
authored
feat(extensions): support .extensionignore to exclude files during install (#1781)
* 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 * fix(extensions): use pathspec for gitignore-compatible .extensionignore matching Replace fnmatch with pathspec.GitIgnoreSpec to get proper .gitignore semantics where * does not cross directory boundaries. This addresses review feedback on #1781. Changes: - Switch from fnmatch to pathspec>=0.12.0 (GitIgnoreSpec.from_lines) - Normalize backslashes in patterns for cross-platform compatibility - Distinguish directories from files for trailing-slash patterns - Update docs to accurately describe supported pattern semantics - Add edge-case tests: .., absolute paths, empty file, backslashes, * vs ** boundary behavior, and ! negation - Move changelog entry to [Unreleased] section
1 parent 4ab91fb commit 2632a0f

File tree

5 files changed

+477
-3
lines changed

5 files changed

+477
-3
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
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+
## [Unreleased]
11+
12+
### Added
13+
14+
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
15+
1016
## [0.2.0] - 2026-03-09
1117

1218
### Changed
@@ -43,7 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4349
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
4450
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
4551

46-
4752
## [0.1.14] - 2026-03-09
4853

4954
### Added

extensions/EXTENSION-DEVELOPMENT-GUIDE.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,67 @@ 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 `.gitignore`-compatible patterns (one per line), powered by the [`pathspec`](https://pypi.org/project/pathspec/) library:
342+
343+
- Blank lines are ignored
344+
- Lines starting with `#` are comments
345+
- `*` matches anything **except** `/` (does not cross directory boundaries)
346+
- `**` matches zero or more directories (e.g., `docs/**/*.draft.md`)
347+
- `?` matches any single character except `/`
348+
- A trailing `/` restricts a pattern to directories only
349+
- Patterns containing `/` (other than a trailing slash) are anchored to the extension root
350+
- Patterns without `/` match at any depth in the tree
351+
- `!` negates a previously excluded pattern (re-includes a file)
352+
- Backslashes in patterns are normalised to forward slashes for cross-platform compatibility
353+
- The `.extensionignore` file itself is always excluded automatically
354+
355+
### Example
356+
357+
```gitignore
358+
# .extensionignore
359+
360+
# Development files
361+
tests/
362+
.github/
363+
.gitignore
364+
365+
# Build artifacts
366+
__pycache__/
367+
*.pyc
368+
dist/
369+
370+
# Documentation source (keep only the built README)
371+
docs/
372+
CONTRIBUTING.md
373+
```
374+
375+
### Pattern Matching
376+
377+
| Pattern | Matches | Does NOT match |
378+
|---------|---------|----------------|
379+
| `*.pyc` | Any `.pyc` file in any directory | — |
380+
| `tests/` | The `tests` directory (and all its contents) | A file named `tests` |
381+
| `docs/*.draft.md` | `docs/api.draft.md` (directly inside `docs/`) | `docs/sub/api.draft.md` (nested) |
382+
| `.env` | The `.env` file at any level | — |
383+
| `!README.md` | Re-includes `README.md` even if matched by an earlier pattern | — |
384+
| `docs/**/*.draft.md` | `docs/api.draft.md`, `docs/sub/api.draft.md` | — |
385+
386+
### Unsupported Features
387+
388+
The following `.gitignore` features are **not applicable** in this context:
389+
390+
- **Multiple `.extensionignore` files**: Only a single file at the extension root is supported (`.gitignore` supports files in subdirectories)
391+
- **`$GIT_DIR/info/exclude` and `core.excludesFile`**: These are Git-specific and have no equivalent here
392+
- **Negation inside excluded directories**: Because file copying uses `shutil.copytree`, excluding a directory prevents recursion into it entirely. A negation pattern cannot re-include a file inside a directory that was itself excluded. For example, the combination `tests/` followed by `!tests/important.py` will **not** preserve `tests/important.py` — the `tests/` directory is skipped at the root level and its contents are never evaluated. To work around this, exclude the directory's contents individually instead of the directory itself (e.g., `tests/*.pyc` and `tests/.cache/` rather than `tests/`).
393+
394+
---
395+
335396
## Validation Rules
336397

337398
### Extension ID

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"truststore>=0.10.4",
1414
"pyyaml>=6.0",
1515
"packaging>=23.0",
16+
"pathspec>=0.12.0",
1617
]
1718

1819
[project.scripts]

src/specify_cli/extensions.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
import shutil
1515
from dataclasses import dataclass
1616
from pathlib import Path
17-
from typing import Optional, Dict, List, Any
17+
from typing import Optional, Dict, List, Any, Callable, Set
1818
from datetime import datetime, timezone
1919
import re
2020

21+
import pathspec
22+
2123
import yaml
2224
from packaging import version as pkg_version
2325
from packaging.specifiers import SpecifierSet, InvalidSpecifier
@@ -280,6 +282,70 @@ def __init__(self, project_root: Path):
280282
self.extensions_dir = project_root / ".specify" / "extensions"
281283
self.registry = ExtensionRegistry(self.extensions_dir)
282284

285+
@staticmethod
286+
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
287+
"""Load .extensionignore and return an ignore function for shutil.copytree.
288+
289+
The .extensionignore file uses .gitignore-compatible patterns (one per line).
290+
Lines starting with '#' are comments. Blank lines are ignored.
291+
The .extensionignore file itself is always excluded.
292+
293+
Pattern semantics mirror .gitignore:
294+
- '*' matches anything except '/'
295+
- '**' matches zero or more directories
296+
- '?' matches any single character except '/'
297+
- Trailing '/' restricts a pattern to directories only
298+
- Patterns with '/' (other than trailing) are anchored to the root
299+
- '!' negates a previously excluded pattern
300+
301+
Args:
302+
source_dir: Path to the extension source directory
303+
304+
Returns:
305+
An ignore function compatible with shutil.copytree, or None
306+
if no .extensionignore file exists.
307+
"""
308+
ignore_file = source_dir / ".extensionignore"
309+
if not ignore_file.exists():
310+
return None
311+
312+
lines: List[str] = ignore_file.read_text().splitlines()
313+
314+
# Normalise backslashes in patterns so Windows-authored files work
315+
normalised: List[str] = []
316+
for line in lines:
317+
stripped = line.strip()
318+
if stripped and not stripped.startswith("#"):
319+
normalised.append(stripped.replace("\\", "/"))
320+
else:
321+
# Preserve blanks/comments so pathspec line numbers stay stable
322+
normalised.append(line)
323+
324+
# Always ignore the .extensionignore file itself
325+
normalised.append(".extensionignore")
326+
327+
spec = pathspec.GitIgnoreSpec.from_lines(normalised)
328+
329+
def _ignore(directory: str, entries: List[str]) -> Set[str]:
330+
ignored: Set[str] = set()
331+
rel_dir = Path(directory).relative_to(source_dir)
332+
for entry in entries:
333+
rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry
334+
# Normalise to forward slashes for consistent matching
335+
rel_path_fwd = rel_path.replace("\\", "/")
336+
337+
entry_full = Path(directory) / entry
338+
if entry_full.is_dir():
339+
# Append '/' so directory-only patterns (e.g. tests/) match
340+
if spec.match_file(rel_path_fwd + "/"):
341+
ignored.add(entry)
342+
else:
343+
if spec.match_file(rel_path_fwd):
344+
ignored.add(entry)
345+
return ignored
346+
347+
return _ignore
348+
283349
def check_compatibility(
284350
self,
285351
manifest: ExtensionManifest,
@@ -353,7 +419,8 @@ def install_from_directory(
353419
if dest_dir.exists():
354420
shutil.rmtree(dest_dir)
355421

356-
shutil.copytree(source_dir, dest_dir)
422+
ignore_fn = self._load_extensionignore(source_dir)
423+
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
357424

358425
# Register commands with AI agents
359426
registered_commands = {}

0 commit comments

Comments
 (0)