Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,50 @@ may omit the digest.

> **Note:** All preset commands require a project already initialized with `specify init`.

### Exclude development files with `.presetignore`

Place a UTF-8 `.presetignore` (with or without a byte-order mark) beside
`preset.yml` in the preset source root to filter files copied into
`.specify/presets/<id>/`. For example:

```gitignore
# Development-only directories
.git/
.idea/
tests/
```

Filtering applies to local directory installs and extracted archives, including
reinstalls and replacements installed by `preset update`. Without
`.presetignore`, all source files are copied as before. **`.gitignore` is not
used for installation filtering**, and there are no implicit exclusions for
development files.

Patterns follow the same Git-style matching and normalization as
`.extensionignore`:

- Use one pattern per line; blank lines and `#` comments are ignored.
- `*` and `?` do not cross directory boundaries; `**` can match across directories.
- A trailing `/` matches directories only. A leading `/`, or a `/` within a
pattern other than its trailing slash, anchors the pattern to the source root.
Patterns without such a slash match names at any depth.
- A later `!` pattern re-includes a match. An excluded directory is not traversed,
so re-including a child requires re-including its parent directory too.
- Pattern whitespace is trimmed and backslashes are normalized to `/`, matching
the existing extension behavior. Use UTF-8 for non-ASCII names.

Only the source root's `.presetignore` is read; nested ignore files do not add
rules. Files named `.presetignore` are themselves excluded, even if negated.
Manifest and declared asset files are not exempt from filtering: keep
`preset.yml` and any templates, commands, or scripts the preset needs included.
Excluding them can leave an incomplete or unusable installation.

The source-directory installer reads and validates `.presetignore` before
removing an existing destination, including during a forced reinstall. Invalid
UTF-8 raises a preset validation error. The separate `preset update` command
retains its remove-then-add behavior described below; it does not preflight the
replacement ignore file before removal.

## Update a Preset

```bash
Expand Down
71 changes: 71 additions & 0 deletions src/specify_cli/_install_ignore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Shared source-directory installation filtering for extensions and presets."""

from pathlib import Path
from typing import Callable, List, Optional, Set

import pathspec


def load_install_ignore(
source_dir: Path,
ignore_filename: str,
validation_error: type[Exception],
) -> Optional[Callable[[str, List[str]], Set[str]]]:
"""Load a UTF-8 ignore file as a copytree callback, or return None if absent.

Match the extension installer's Git-style patterns, including whitespace
trimming and backslash normalization. Only the named root ignore file is
read; there are no default exclusions except the ignore filename itself.
Invalid encoding raises the caller's domain validation error before copying.
Excluded directories are pruned by copytree, so child negations cannot
restore files inside them unless the parent directory is also re-included.
"""
ignore_file = source_dir / ignore_filename
if not ignore_file.exists():
return None

# Accept UTF-8 with or without the BOM emitted by Windows PowerShell 5.1.
# Otherwise the BOM becomes part of the first pattern and prevents matching.
try:
raw = ignore_file.read_text(encoding="utf-8-sig")
except UnicodeDecodeError as e:
raise validation_error(
f"{ignore_filename} is not valid UTF-8: {ignore_file} "
f"({e.reason} at byte {e.start})"
)
lines: List[str] = raw.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 ignore file itself, even after a negation pattern.
normalised.append(ignore_filename)

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
64 changes: 2 additions & 62 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
from pathlib import Path
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Set

import pathspec
import yaml
from packaging import version as pkg_version
from packaging.specifiers import InvalidSpecifier, SpecifierSet
Expand All @@ -43,6 +42,7 @@
safe_extract_archive,
)
from .._init_options import is_ai_skills_enabled
from .._install_ignore import load_install_ignore
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
from .._utils import dump_frontmatter, relative_extension_path_violation, version_satisfies
from ..catalogs import CatalogEntry as BaseCatalogEntry
Expand Down Expand Up @@ -1275,67 +1275,7 @@ def _load_extensionignore(
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

# Pin UTF-8 explicitly: ``Path.read_text`` defaults to the system
# locale codec on Windows (cp1252 / gb2312 / cp932), which silently
# corrupts multibyte patterns when the file is shared across
# machines with different locales. The next line already
# normalises backslashes "so Windows-authored files work" — the
# codebase already expects Windows authors to write this file.
#
# A file that is not valid UTF-8 is a user-authoring mistake, so
# surface it as ``ValidationError`` with a pointer to the offending
# byte — the same pattern ``ExtensionManifest._load_yaml`` uses
# for ``extension.yml`` (see ``UnicodeDecodeError`` handler in
# this module). Without the wrap, the raw ``UnicodeDecodeError``
# would abort installation with a Python traceback instead of a
# clear message naming the file.
try:
raw = ignore_file.read_text(encoding="utf-8")
except UnicodeDecodeError as e:
raise ValidationError(
f".extensionignore is not valid UTF-8: {ignore_file} "
f"({e.reason} at byte {e.start})"
)
lines: List[str] = raw.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
return load_install_ignore(source_dir, ".extensionignore", ValidationError)

@project_registration
def _get_skills_dir(self, *, create: bool = True) -> Optional[Path]:
Expand Down
6 changes: 5 additions & 1 deletion src/specify_cli/presets/_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from .._download_security import safe_extract_archive
from .._init_options import is_ai_skills_enabled, resolve_active_agent_for_registration
from .._install_ignore import load_install_ignore
from .._utils import version_satisfies
from ..extensions import REINSTALL_COMMAND, normalize_priority
from ..shared_infra import (
Expand Down Expand Up @@ -402,13 +403,16 @@ def install_from_directory(
f"Preset '{manifest.id}' is already installed. "
f"Use 'specify preset remove {manifest.id}' first."
)
# Validate before remove() changes registry/agent artifacts or deleting
# an unregistered destination left behind by an earlier installation.
ignore_fn = load_install_ignore(source_dir, ".presetignore", PresetValidationError)
before_file_change(dest_dir, removal=True)
try:
if installed:
self.remove(manifest.id)
if dest_dir.exists():
shutil.rmtree(dest_dir)
shutil.copytree(source_dir, dest_dir)
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
finally:
after_file_change(dest_dir)

Expand Down
8 changes: 7 additions & 1 deletion tests/integration/test_preset_update_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ def test_preset_update_cli_contract():
assert "--dry-run" not in help_output


def test_preset_update_replaces_installed_preset(project: Path, tmp_path: Path):
@pytest.mark.parametrize("use_ignore", [False, True])
def test_preset_update_replaces_installed_preset(project: Path, tmp_path: Path, use_ignore):
"""A successful update really swaps the installed preset on disk."""
runner = CliRunner()

Expand All @@ -178,6 +179,9 @@ def test_preset_update_replaces_installed_preset(project: Path, tmp_path: Path):
body="# Version Two Template\n",
extra_file="only-in-v2.md",
)
(replacement / "dev-only.txt").write_text("development notes\n", encoding="utf-8")
if use_ignore:
(replacement / ".presetignore").write_text("dev-only.txt\n", encoding="utf-8")

install = runner.invoke(
app, ["preset", "add", "--dev", str(original), "--priority", "20"]
Expand All @@ -201,6 +205,8 @@ def test_preset_update_replaces_installed_preset(project: Path, tmp_path: Path):
template = installed_dir / "templates" / "spec-template.md"
assert template.read_text(encoding="utf-8") == "# Version Two Template\n"
assert (installed_dir / "only-in-v2.md").exists()
assert (installed_dir / "dev-only.txt").exists() is not use_ignore
assert not (installed_dir / ".presetignore").exists()
assert not (installed_dir / "only-in-v1.md").exists(), (
"the previous preset's files must be removed, not merged with the "
"replacement"
Expand Down
115 changes: 115 additions & 0 deletions tests/specify_cli/presets/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,121 @@ def unreadable(path, *args, **kwargs):
)


class TestPresetIgnore:
"""Installation filtering follows the extension ignore contract."""

@staticmethod
def _write_files(root, paths):
for relative in paths:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"contents of {relative}\n", encoding="utf-8")

@pytest.mark.parametrize("ignore_content", [None, "", "!.presetignore\n"])
def test_no_default_exclusions(self, project_dir, pack_dir, ignore_content):
paths = [".git/config", ".idea/project.xml", "tests/test_example.py", "debug.log"]
self._write_files(pack_dir, paths)
(pack_dir / ".gitignore").write_text("*\n", encoding="utf-8")
(pack_dir / ".extensionignore").write_text("*\n", encoding="utf-8")
if ignore_content is not None:
(pack_dir / ".presetignore").write_text(ignore_content, encoding="utf-8")

PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5")

dest = project_dir / ".specify/presets/test-pack"
for relative in [
*paths, ".gitignore", ".extensionignore", "preset.yml", "templates/spec-template.md"
]:
assert (dest / relative).read_bytes() == (pack_dir / relative).read_bytes()
assert not (dest / ".presetignore").exists()

@pytest.mark.parametrize("root_ignore", [False, True])
def test_nested_ignore_is_not_loaded(self, project_dir, pack_dir, root_ignore):
self._write_files(pack_dir, ["nested/keep.txt"])
(pack_dir / "nested/.presetignore").write_bytes(b"*\n\xff\n")
if root_ignore:
(pack_dir / ".presetignore").write_text("", encoding="utf-8")

PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5")

dest = project_dir / ".specify/presets/test-pack/nested"
assert (dest / "keep.txt").is_file()
assert (dest / ".presetignore").exists() is not root_ignore

@pytest.mark.parametrize("registered", [False, True])
def test_invalid_utf8_preserves_existing_state(
self, project_dir, pack_dir, valid_pack_data, registered
):
"""Validate before deleting even an unregistered leftover destination."""
valid_pack_data["provides"]["templates"].append({
"type": "command", "name": "speckit.ignore.demo", "file": "commands/demo.md",
"description": "Ignore test command",
})
(pack_dir / "preset.yml").write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8")
self._write_files(pack_dir, ["commands/demo.md"])
(project_dir / ".claude/skills").mkdir(parents=True)
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
assert manager.registry.get("test-pack")["registered_commands"]
if not registered:
manager.registry.remove("test-pack")

def snapshot():
return {
str(path.relative_to(project_dir)): path.read_bytes()
for path in project_dir.rglob("*") if path.is_file()
}

before = snapshot()
(pack_dir / ".presetignore").write_bytes(b"caf\xe9/\n")
with pytest.raises(PresetValidationError, match=r"\.presetignore is not valid UTF-8.*byte 3"):
manager.install_from_directory(pack_dir, "0.1.5", force=registered)

assert snapshot() == before
assert manager.registry.is_installed("test-pack") is registered

@pytest.mark.parametrize("route", ["directory", "zip", "tar"])
def test_reinstall_filters_new_source(self, project_dir, pack_dir, temp_dir, route):
self._write_files(pack_dir, ["tests/dev.py", "keep.txt"])
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
dest = project_dir / ".specify/presets/test-pack"
assert (dest / "tests/dev.py").is_file()
(pack_dir / ".presetignore").write_text("tests/\n", encoding="utf-8")
(pack_dir / "keep.txt").write_text("updated\n", encoding="utf-8")

if route == "directory":
manager.install_from_directory(pack_dir, "0.1.5", force=True)
elif route == "zip":
archive_path = temp_dir / "preset.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
for path in pack_dir.rglob("*"):
if path.is_file():
archive.write(path, Path("package") / path.relative_to(pack_dir))
manager.install_from_zip(archive_path, "0.1.5", force=True)
else:
archive_path = temp_dir / "preset.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(pack_dir, arcname="package")
manager.install_from_archive(archive_path, "0.1.5", force=True)

assert not (dest / "tests").exists()
assert not (dest / ".presetignore").exists()
assert (dest / "keep.txt").read_text(encoding="utf-8") == "updated\n"
assert manager.registry.is_installed("test-pack")

@pytest.mark.parametrize("excluded", ["preset.yml", "templates/spec-template.md"])
def test_declared_files_are_not_exempt(self, project_dir, pack_dir, excluded):
"""Like extensions, filtering does not protect the manifest or declared assets."""
(pack_dir / ".presetignore").write_text(excluded + "\n", encoding="utf-8")
manager = PresetManager(project_dir)
manifest = manager.install_from_directory(pack_dir, "0.1.5")

assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
assert not (project_dir / ".specify/presets/test-pack" / excluded).exists()


class TestPresetManager:
"""Test PresetManager installation and removal."""

Expand Down
Loading
Loading