Skip to content

Commit 914a06a

Browse files
committed
Merge remote-tracking branch 'upstream/main' into pr-1787
# Conflicts: # CHANGELOG.md # src/specify_cli/extensions.py
2 parents abf4aeb + 2632a0f commit 914a06a

7 files changed

Lines changed: 490 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`)
2525
- Preset scaffold directory (`presets/scaffold/`)
2626
- Scripts updated to use template resolution instead of hardcoded paths
27+
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
2728

2829
## [0.2.0] - 2026-03-09
2930

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

64-
6565
## [0.1.14] - 2026-03-09
6666

6767
### 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/agents.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ class CommandRegistrar:
5858
"args": "$ARGUMENTS",
5959
"extension": ".md"
6060
},
61+
"codex": {
62+
"dir": ".codex/prompts",
63+
"format": "markdown",
64+
"args": "$ARGUMENTS",
65+
"extension": ".md"
66+
},
6167
"windsurf": {
6268
"dir": ".windsurf/workflows",
6369
"format": "markdown",

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 = {}

tests/test_agent_config_consistency.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ def test_extension_registrar_uses_kiro_cli_and_removes_q(self):
2828
assert cfg["kiro-cli"]["dir"] == ".kiro/prompts"
2929
assert "q" not in cfg
3030

31+
def test_extension_registrar_includes_codex(self):
32+
"""Extension command registrar should include codex targeting .codex/prompts."""
33+
cfg = CommandRegistrar.AGENT_CONFIGS
34+
35+
assert "codex" in cfg
36+
assert cfg["codex"]["dir"] == ".codex/prompts"
37+
3138
def test_release_agent_lists_include_kiro_cli_and_exclude_q(self):
3239
"""Bash and PowerShell release scripts should agree on agent key set for Kiro."""
3340
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")

0 commit comments

Comments
 (0)