@@ -1774,3 +1774,167 @@ def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data
17741774 dest = proj_dir / ".specify" / "extensions" / "test-ext"
17751775 assert (dest / "docs" / "guide.md" ).exists ()
17761776 assert not (dest / "docs" / "internal" / "draft.md" ).exists ()
1777+
1778+ def test_extensionignore_dotdot_pattern_is_noop (self , temp_dir , valid_manifest_data ):
1779+ """Patterns with '..' should not escape the extension root."""
1780+ ext_dir = self ._make_extension (
1781+ temp_dir ,
1782+ valid_manifest_data ,
1783+ extra_files = {"README.md" : "# Hello" },
1784+ ignore_content = "../sibling/\n " ,
1785+ )
1786+
1787+ proj_dir = temp_dir / "project"
1788+ proj_dir .mkdir ()
1789+ (proj_dir / ".specify" ).mkdir ()
1790+
1791+ manager = ExtensionManager (proj_dir )
1792+ manager .install_from_directory (ext_dir , "0.1.0" , register_commands = False )
1793+
1794+ dest = proj_dir / ".specify" / "extensions" / "test-ext"
1795+ # Everything should still be copied — the '..' pattern matches nothing inside
1796+ assert (dest / "README.md" ).exists ()
1797+ assert (dest / "extension.yml" ).exists ()
1798+ assert (dest / "commands" / "hello.md" ).exists ()
1799+
1800+ def test_extensionignore_absolute_path_pattern_is_noop (self , temp_dir , valid_manifest_data ):
1801+ """Absolute path patterns should not match anything."""
1802+ ext_dir = self ._make_extension (
1803+ temp_dir ,
1804+ valid_manifest_data ,
1805+ extra_files = {"README.md" : "# Hello" , "passwd" : "sensitive" },
1806+ ignore_content = "/etc/passwd\n " ,
1807+ )
1808+
1809+ proj_dir = temp_dir / "project"
1810+ proj_dir .mkdir ()
1811+ (proj_dir / ".specify" ).mkdir ()
1812+
1813+ manager = ExtensionManager (proj_dir )
1814+ manager .install_from_directory (ext_dir , "0.1.0" , register_commands = False )
1815+
1816+ dest = proj_dir / ".specify" / "extensions" / "test-ext"
1817+ # Nothing matches — /etc/passwd is anchored to root and there's no 'etc' dir
1818+ assert (dest / "README.md" ).exists ()
1819+ assert (dest / "passwd" ).exists ()
1820+
1821+ def test_extensionignore_empty_file (self , temp_dir , valid_manifest_data ):
1822+ """An empty .extensionignore should exclude only itself."""
1823+ ext_dir = self ._make_extension (
1824+ temp_dir ,
1825+ valid_manifest_data ,
1826+ extra_files = {"README.md" : "# Hello" , "notes.txt" : "notes" },
1827+ ignore_content = "" ,
1828+ )
1829+
1830+ proj_dir = temp_dir / "project"
1831+ proj_dir .mkdir ()
1832+ (proj_dir / ".specify" ).mkdir ()
1833+
1834+ manager = ExtensionManager (proj_dir )
1835+ manager .install_from_directory (ext_dir , "0.1.0" , register_commands = False )
1836+
1837+ dest = proj_dir / ".specify" / "extensions" / "test-ext"
1838+ assert (dest / "README.md" ).exists ()
1839+ assert (dest / "notes.txt" ).exists ()
1840+ assert (dest / "extension.yml" ).exists ()
1841+ # .extensionignore itself is still excluded
1842+ assert not (dest / ".extensionignore" ).exists ()
1843+
1844+ def test_extensionignore_windows_backslash_patterns (self , temp_dir , valid_manifest_data ):
1845+ """Backslash patterns (Windows-style) are normalised to forward slashes."""
1846+ ext_dir = self ._make_extension (
1847+ temp_dir ,
1848+ valid_manifest_data ,
1849+ extra_files = {
1850+ "docs/internal/draft.md" : "draft" ,
1851+ "docs/guide.md" : "# Guide" ,
1852+ },
1853+ ignore_content = "docs\\ internal\\ draft.md\n " ,
1854+ )
1855+
1856+ proj_dir = temp_dir / "project"
1857+ proj_dir .mkdir ()
1858+ (proj_dir / ".specify" ).mkdir ()
1859+
1860+ manager = ExtensionManager (proj_dir )
1861+ manager .install_from_directory (ext_dir , "0.1.0" , register_commands = False )
1862+
1863+ dest = proj_dir / ".specify" / "extensions" / "test-ext"
1864+ assert (dest / "docs" / "guide.md" ).exists ()
1865+ assert not (dest / "docs" / "internal" / "draft.md" ).exists ()
1866+
1867+ def test_extensionignore_star_does_not_cross_directories (self , temp_dir , valid_manifest_data ):
1868+ """'*' should NOT match across directory boundaries (gitignore semantics)."""
1869+ ext_dir = self ._make_extension (
1870+ temp_dir ,
1871+ valid_manifest_data ,
1872+ extra_files = {
1873+ "docs/api.draft.md" : "draft" ,
1874+ "docs/sub/api.draft.md" : "nested draft" ,
1875+ },
1876+ ignore_content = "docs/*.draft.md\n " ,
1877+ )
1878+
1879+ proj_dir = temp_dir / "project"
1880+ proj_dir .mkdir ()
1881+ (proj_dir / ".specify" ).mkdir ()
1882+
1883+ manager = ExtensionManager (proj_dir )
1884+ manager .install_from_directory (ext_dir , "0.1.0" , register_commands = False )
1885+
1886+ dest = proj_dir / ".specify" / "extensions" / "test-ext"
1887+ # docs/*.draft.md should only match directly inside docs/, NOT subdirs
1888+ assert not (dest / "docs" / "api.draft.md" ).exists ()
1889+ assert (dest / "docs" / "sub" / "api.draft.md" ).exists ()
1890+
1891+ def test_extensionignore_doublestar_crosses_directories (self , temp_dir , valid_manifest_data ):
1892+ """'**' should match across directory boundaries."""
1893+ ext_dir = self ._make_extension (
1894+ temp_dir ,
1895+ valid_manifest_data ,
1896+ extra_files = {
1897+ "docs/api.draft.md" : "draft" ,
1898+ "docs/sub/api.draft.md" : "nested draft" ,
1899+ "docs/guide.md" : "guide" ,
1900+ },
1901+ ignore_content = "docs/**/*.draft.md\n " ,
1902+ )
1903+
1904+ proj_dir = temp_dir / "project"
1905+ proj_dir .mkdir ()
1906+ (proj_dir / ".specify" ).mkdir ()
1907+
1908+ manager = ExtensionManager (proj_dir )
1909+ manager .install_from_directory (ext_dir , "0.1.0" , register_commands = False )
1910+
1911+ dest = proj_dir / ".specify" / "extensions" / "test-ext"
1912+ assert not (dest / "docs" / "api.draft.md" ).exists ()
1913+ assert not (dest / "docs" / "sub" / "api.draft.md" ).exists ()
1914+ assert (dest / "docs" / "guide.md" ).exists ()
1915+
1916+ def test_extensionignore_negation_pattern (self , temp_dir , valid_manifest_data ):
1917+ """'!' negation re-includes a previously excluded file."""
1918+ ext_dir = self ._make_extension (
1919+ temp_dir ,
1920+ valid_manifest_data ,
1921+ extra_files = {
1922+ "docs/guide.md" : "# Guide" ,
1923+ "docs/internal.md" : "internal" ,
1924+ "docs/api.md" : "api" ,
1925+ },
1926+ ignore_content = "docs/*.md\n !docs/api.md\n " ,
1927+ )
1928+
1929+ proj_dir = temp_dir / "project"
1930+ proj_dir .mkdir ()
1931+ (proj_dir / ".specify" ).mkdir ()
1932+
1933+ manager = ExtensionManager (proj_dir )
1934+ manager .install_from_directory (ext_dir , "0.1.0" , register_commands = False )
1935+
1936+ dest = proj_dir / ".specify" / "extensions" / "test-ext"
1937+ # docs/*.md excludes all .md in docs, but !docs/api.md re-includes it
1938+ assert not (dest / "docs" / "guide.md" ).exists ()
1939+ assert not (dest / "docs" / "internal.md" ).exists ()
1940+ assert (dest / "docs" / "api.md" ).exists ()
0 commit comments