|
14 | 14 | import shutil |
15 | 15 | from dataclasses import dataclass |
16 | 16 | from pathlib import Path |
17 | | -from typing import Optional, Dict, List, Any |
| 17 | +from typing import Optional, Dict, List, Any, Callable, Set |
18 | 18 | from datetime import datetime, timezone |
19 | 19 | import re |
20 | 20 |
|
| 21 | +import pathspec |
| 22 | + |
21 | 23 | import yaml |
22 | 24 | from packaging import version as pkg_version |
23 | 25 | from packaging.specifiers import SpecifierSet, InvalidSpecifier |
@@ -280,6 +282,70 @@ def __init__(self, project_root: Path): |
280 | 282 | self.extensions_dir = project_root / ".specify" / "extensions" |
281 | 283 | self.registry = ExtensionRegistry(self.extensions_dir) |
282 | 284 |
|
| 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 | + |
283 | 349 | def check_compatibility( |
284 | 350 | self, |
285 | 351 | manifest: ExtensionManifest, |
@@ -353,7 +419,8 @@ def install_from_directory( |
353 | 419 | if dest_dir.exists(): |
354 | 420 | shutil.rmtree(dest_dir) |
355 | 421 |
|
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) |
357 | 424 |
|
358 | 425 | # Register commands with AI agents |
359 | 426 | registered_commands = {} |
|
0 commit comments