mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 02:43:08 +00:00
Compare commits
2 Commits
4ab91fbadf
...
56095f06d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56095f06d2 | ||
|
|
2632a0f52d |
@@ -7,6 +7,12 @@ Recent changes to the Specify CLI and templates are documented here.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
|
||||||
|
|
||||||
## [0.2.0] - 2026-03-09
|
## [0.2.0] - 2026-03-09
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -43,7 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
|
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
|
||||||
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
||||||
|
|
||||||
|
|
||||||
## [0.1.14] - 2026-03-09
|
## [0.1.14] - 2026-03-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -332,6 +332,67 @@ echo "$config"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Excluding Files with `.extensionignore`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Format
|
||||||
|
|
||||||
|
The file uses `.gitignore`-compatible patterns (one per line), powered by the [`pathspec`](https://pypi.org/project/pathspec/) library:
|
||||||
|
|
||||||
|
- Blank lines are ignored
|
||||||
|
- Lines starting with `#` are comments
|
||||||
|
- `*` matches anything **except** `/` (does not cross directory boundaries)
|
||||||
|
- `**` matches zero or more directories (e.g., `docs/**/*.draft.md`)
|
||||||
|
- `?` matches any single character except `/`
|
||||||
|
- A trailing `/` restricts a pattern to directories only
|
||||||
|
- Patterns containing `/` (other than a trailing slash) are anchored to the extension root
|
||||||
|
- Patterns without `/` match at any depth in the tree
|
||||||
|
- `!` negates a previously excluded pattern (re-includes a file)
|
||||||
|
- Backslashes in patterns are normalised to forward slashes for cross-platform compatibility
|
||||||
|
- The `.extensionignore` file itself is always excluded automatically
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# .extensionignore
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
tests/
|
||||||
|
.github/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Documentation source (keep only the built README)
|
||||||
|
docs/
|
||||||
|
CONTRIBUTING.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern Matching
|
||||||
|
|
||||||
|
| Pattern | Matches | Does NOT match |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| `*.pyc` | Any `.pyc` file in any directory | — |
|
||||||
|
| `tests/` | The `tests` directory (and all its contents) | A file named `tests` |
|
||||||
|
| `docs/*.draft.md` | `docs/api.draft.md` (directly inside `docs/`) | `docs/sub/api.draft.md` (nested) |
|
||||||
|
| `.env` | The `.env` file at any level | — |
|
||||||
|
| `!README.md` | Re-includes `README.md` even if matched by an earlier pattern | — |
|
||||||
|
| `docs/**/*.draft.md` | `docs/api.draft.md`, `docs/sub/api.draft.md` | — |
|
||||||
|
|
||||||
|
### Unsupported Features
|
||||||
|
|
||||||
|
The following `.gitignore` features are **not applicable** in this context:
|
||||||
|
|
||||||
|
- **Multiple `.extensionignore` files**: Only a single file at the extension root is supported (`.gitignore` supports files in subdirectories)
|
||||||
|
- **`$GIT_DIR/info/exclude` and `core.excludesFile`**: These are Git-specific and have no equivalent here
|
||||||
|
- **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/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Validation Rules
|
## Validation Rules
|
||||||
|
|
||||||
### Extension ID
|
### Extension ID
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"truststore>=0.10.4",
|
"truststore>=0.10.4",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"packaging>=23.0",
|
"packaging>=23.0",
|
||||||
|
"pathspec>=0.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ if ($branchName.Length -gt $maxBranchLength) {
|
|||||||
if ($hasGit) {
|
if ($hasGit) {
|
||||||
$branchCreated = $false
|
$branchCreated = $false
|
||||||
try {
|
try {
|
||||||
git checkout -b $branchName 2>$null | Out-Null
|
git checkout -q -b $branchName 2>$null | Out-Null
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
$branchCreated = $true
|
$branchCreated = $true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import zipfile
|
|||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List, Any, Callable, Set
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import pathspec
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from packaging import version as pkg_version
|
from packaging import version as pkg_version
|
||||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||||
@@ -280,6 +282,70 @@ class ExtensionManager:
|
|||||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||||
self.registry = ExtensionRegistry(self.extensions_dir)
|
self.registry = ExtensionRegistry(self.extensions_dir)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
|
||||||
|
"""Load .extensionignore and return an ignore function for shutil.copytree.
|
||||||
|
|
||||||
|
The .extensionignore file uses .gitignore-compatible patterns (one per line).
|
||||||
|
Lines starting with '#' are comments. Blank lines are ignored.
|
||||||
|
The .extensionignore file itself is always excluded.
|
||||||
|
|
||||||
|
Pattern semantics mirror .gitignore:
|
||||||
|
- '*' matches anything except '/'
|
||||||
|
- '**' matches zero or more directories
|
||||||
|
- '?' matches any single character except '/'
|
||||||
|
- Trailing '/' restricts a pattern to directories only
|
||||||
|
- Patterns with '/' (other than trailing) are anchored to the root
|
||||||
|
- '!' negates a previously excluded pattern
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_dir: Path to the extension source directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An ignore function compatible with shutil.copytree, or None
|
||||||
|
if no .extensionignore file exists.
|
||||||
|
"""
|
||||||
|
ignore_file = source_dir / ".extensionignore"
|
||||||
|
if not ignore_file.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
lines: List[str] = ignore_file.read_text().splitlines()
|
||||||
|
|
||||||
|
# Normalise backslashes in patterns so Windows-authored files work
|
||||||
|
normalised: List[str] = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped and not stripped.startswith("#"):
|
||||||
|
normalised.append(stripped.replace("\\", "/"))
|
||||||
|
else:
|
||||||
|
# Preserve blanks/comments so pathspec line numbers stay stable
|
||||||
|
normalised.append(line)
|
||||||
|
|
||||||
|
# Always ignore the .extensionignore file itself
|
||||||
|
normalised.append(".extensionignore")
|
||||||
|
|
||||||
|
spec = pathspec.GitIgnoreSpec.from_lines(normalised)
|
||||||
|
|
||||||
|
def _ignore(directory: str, entries: List[str]) -> Set[str]:
|
||||||
|
ignored: Set[str] = set()
|
||||||
|
rel_dir = Path(directory).relative_to(source_dir)
|
||||||
|
for entry in entries:
|
||||||
|
rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry
|
||||||
|
# Normalise to forward slashes for consistent matching
|
||||||
|
rel_path_fwd = rel_path.replace("\\", "/")
|
||||||
|
|
||||||
|
entry_full = Path(directory) / entry
|
||||||
|
if entry_full.is_dir():
|
||||||
|
# Append '/' so directory-only patterns (e.g. tests/) match
|
||||||
|
if spec.match_file(rel_path_fwd + "/"):
|
||||||
|
ignored.add(entry)
|
||||||
|
else:
|
||||||
|
if spec.match_file(rel_path_fwd):
|
||||||
|
ignored.add(entry)
|
||||||
|
return ignored
|
||||||
|
|
||||||
|
return _ignore
|
||||||
|
|
||||||
def check_compatibility(
|
def check_compatibility(
|
||||||
self,
|
self,
|
||||||
manifest: ExtensionManifest,
|
manifest: ExtensionManifest,
|
||||||
@@ -353,7 +419,8 @@ class ExtensionManager:
|
|||||||
if dest_dir.exists():
|
if dest_dir.exists():
|
||||||
shutil.rmtree(dest_dir)
|
shutil.rmtree(dest_dir)
|
||||||
|
|
||||||
shutil.copytree(source_dir, dest_dir)
|
ignore_fn = self._load_extensionignore(source_dir)
|
||||||
|
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
|
||||||
|
|
||||||
# Register commands with AI agents
|
# Register commands with AI agents
|
||||||
registered_commands = {}
|
registered_commands = {}
|
||||||
|
|||||||
@@ -1603,3 +1603,343 @@ class TestCatalogStack:
|
|||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0]["_catalog_name"] == "org"
|
assert results[0]["_catalog_name"] == "org"
|
||||||
assert results[0]["_install_allowed"] is True
|
assert results[0]["_install_allowed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtensionIgnore:
|
||||||
|
"""Test .extensionignore support during extension installation."""
|
||||||
|
|
||||||
|
def _make_extension(self, temp_dir, valid_manifest_data, extra_files=None, ignore_content=None):
|
||||||
|
"""Helper to create an extension directory with optional extra files and .extensionignore."""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "ignored-ext"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
|
||||||
|
# Write manifest
|
||||||
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
|
yaml.dump(valid_manifest_data, f)
|
||||||
|
|
||||||
|
# Create commands directory with a command file
|
||||||
|
commands_dir = ext_dir / "commands"
|
||||||
|
commands_dir.mkdir()
|
||||||
|
(commands_dir / "hello.md").write_text(
|
||||||
|
"---\ndescription: \"Test hello command\"\n---\n\n# Hello\n\n$ARGUMENTS\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create any extra files/dirs
|
||||||
|
if extra_files:
|
||||||
|
for rel_path, content in extra_files.items():
|
||||||
|
p = ext_dir / rel_path
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if content is None:
|
||||||
|
# Create directory
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
p.write_text(content)
|
||||||
|
|
||||||
|
# Write .extensionignore
|
||||||
|
if ignore_content is not None:
|
||||||
|
(ext_dir / ".extensionignore").write_text(ignore_content)
|
||||||
|
|
||||||
|
return ext_dir
|
||||||
|
|
||||||
|
def test_no_extensionignore(self, temp_dir, valid_manifest_data):
|
||||||
|
"""Without .extensionignore, all files are copied."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={"README.md": "# Hello", "tests/test_foo.py": "pass"},
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
assert (dest / "README.md").exists()
|
||||||
|
assert (dest / "tests" / "test_foo.py").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_excludes_files(self, temp_dir, valid_manifest_data):
|
||||||
|
"""Files matching .extensionignore patterns are excluded."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={
|
||||||
|
"README.md": "# Hello",
|
||||||
|
"tests/test_foo.py": "pass",
|
||||||
|
"tests/test_bar.py": "pass",
|
||||||
|
".github/workflows/ci.yml": "on: push",
|
||||||
|
},
|
||||||
|
ignore_content="tests/\n.github/\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
# Included
|
||||||
|
assert (dest / "README.md").exists()
|
||||||
|
assert (dest / "extension.yml").exists()
|
||||||
|
assert (dest / "commands" / "hello.md").exists()
|
||||||
|
# Excluded
|
||||||
|
assert not (dest / "tests").exists()
|
||||||
|
assert not (dest / ".github").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_glob_patterns(self, temp_dir, valid_manifest_data):
|
||||||
|
"""Glob patterns like *.pyc are respected."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={
|
||||||
|
"README.md": "# Hello",
|
||||||
|
"helpers.pyc": b"\x00".decode("latin-1"),
|
||||||
|
"commands/cache.pyc": b"\x00".decode("latin-1"),
|
||||||
|
},
|
||||||
|
ignore_content="*.pyc\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
assert (dest / "README.md").exists()
|
||||||
|
assert not (dest / "helpers.pyc").exists()
|
||||||
|
assert not (dest / "commands" / "cache.pyc").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_comments_and_blanks(self, temp_dir, valid_manifest_data):
|
||||||
|
"""Comments and blank lines in .extensionignore are ignored."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={"README.md": "# Hello", "notes.txt": "some notes"},
|
||||||
|
ignore_content="# This is a comment\n\nnotes.txt\n\n# Another comment\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
assert (dest / "README.md").exists()
|
||||||
|
assert not (dest / "notes.txt").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_itself_excluded(self, temp_dir, valid_manifest_data):
|
||||||
|
""".extensionignore is never copied to the destination."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
ignore_content="# nothing special here\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
assert (dest / "extension.yml").exists()
|
||||||
|
assert not (dest / ".extensionignore").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data):
|
||||||
|
"""Patterns matching relative paths work correctly."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={
|
||||||
|
"docs/guide.md": "# Guide",
|
||||||
|
"docs/internal/draft.md": "draft",
|
||||||
|
"README.md": "# Hello",
|
||||||
|
},
|
||||||
|
ignore_content="docs/internal/draft.md\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
assert (dest / "docs" / "guide.md").exists()
|
||||||
|
assert not (dest / "docs" / "internal" / "draft.md").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_dotdot_pattern_is_noop(self, temp_dir, valid_manifest_data):
|
||||||
|
"""Patterns with '..' should not escape the extension root."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={"README.md": "# Hello"},
|
||||||
|
ignore_content="../sibling/\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
# Everything should still be copied — the '..' pattern matches nothing inside
|
||||||
|
assert (dest / "README.md").exists()
|
||||||
|
assert (dest / "extension.yml").exists()
|
||||||
|
assert (dest / "commands" / "hello.md").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_absolute_path_pattern_is_noop(self, temp_dir, valid_manifest_data):
|
||||||
|
"""Absolute path patterns should not match anything."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={"README.md": "# Hello", "passwd": "sensitive"},
|
||||||
|
ignore_content="/etc/passwd\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
# Nothing matches — /etc/passwd is anchored to root and there's no 'etc' dir
|
||||||
|
assert (dest / "README.md").exists()
|
||||||
|
assert (dest / "passwd").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_empty_file(self, temp_dir, valid_manifest_data):
|
||||||
|
"""An empty .extensionignore should exclude only itself."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={"README.md": "# Hello", "notes.txt": "notes"},
|
||||||
|
ignore_content="",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
assert (dest / "README.md").exists()
|
||||||
|
assert (dest / "notes.txt").exists()
|
||||||
|
assert (dest / "extension.yml").exists()
|
||||||
|
# .extensionignore itself is still excluded
|
||||||
|
assert not (dest / ".extensionignore").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_windows_backslash_patterns(self, temp_dir, valid_manifest_data):
|
||||||
|
"""Backslash patterns (Windows-style) are normalised to forward slashes."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={
|
||||||
|
"docs/internal/draft.md": "draft",
|
||||||
|
"docs/guide.md": "# Guide",
|
||||||
|
},
|
||||||
|
ignore_content="docs\\internal\\draft.md\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
assert (dest / "docs" / "guide.md").exists()
|
||||||
|
assert not (dest / "docs" / "internal" / "draft.md").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data):
|
||||||
|
"""'*' should NOT match across directory boundaries (gitignore semantics)."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={
|
||||||
|
"docs/api.draft.md": "draft",
|
||||||
|
"docs/sub/api.draft.md": "nested draft",
|
||||||
|
},
|
||||||
|
ignore_content="docs/*.draft.md\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
# docs/*.draft.md should only match directly inside docs/, NOT subdirs
|
||||||
|
assert not (dest / "docs" / "api.draft.md").exists()
|
||||||
|
assert (dest / "docs" / "sub" / "api.draft.md").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_doublestar_crosses_directories(self, temp_dir, valid_manifest_data):
|
||||||
|
"""'**' should match across directory boundaries."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={
|
||||||
|
"docs/api.draft.md": "draft",
|
||||||
|
"docs/sub/api.draft.md": "nested draft",
|
||||||
|
"docs/guide.md": "guide",
|
||||||
|
},
|
||||||
|
ignore_content="docs/**/*.draft.md\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
assert not (dest / "docs" / "api.draft.md").exists()
|
||||||
|
assert not (dest / "docs" / "sub" / "api.draft.md").exists()
|
||||||
|
assert (dest / "docs" / "guide.md").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data):
|
||||||
|
"""'!' negation re-includes a previously excluded file."""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={
|
||||||
|
"docs/guide.md": "# Guide",
|
||||||
|
"docs/internal.md": "internal",
|
||||||
|
"docs/api.md": "api",
|
||||||
|
},
|
||||||
|
ignore_content="docs/*.md\n!docs/api.md\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
# docs/*.md excludes all .md in docs, but !docs/api.md re-includes it
|
||||||
|
assert not (dest / "docs" / "guide.md").exists()
|
||||||
|
assert not (dest / "docs" / "internal.md").exists()
|
||||||
|
assert (dest / "docs" / "api.md").exists()
|
||||||
|
|||||||
Reference in New Issue
Block a user