deployforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. deployforge/__init__.py +3 -0
  2. deployforge/__version__.py +3 -0
  3. deployforge/analyzer/__init__.py +23 -0
  4. deployforge/analyzer/backend.py +326 -0
  5. deployforge/analyzer/database.py +120 -0
  6. deployforge/analyzer/frontend.py +179 -0
  7. deployforge/analyzer/project.py +389 -0
  8. deployforge/analyzer/shared.py +109 -0
  9. deployforge/cli.py +825 -0
  10. deployforge/config.py +195 -0
  11. deployforge/deployment/__init__.py +19 -0
  12. deployforge/deployment/orchestrator.py +331 -0
  13. deployforge/deployment/planner.py +172 -0
  14. deployforge/deployment/verifier.py +65 -0
  15. deployforge/errors/__init__.py +53 -0
  16. deployforge/github/__init__.py +21 -0
  17. deployforge/github/integration.py +127 -0
  18. deployforge/integration/__init__.py +20 -0
  19. deployforge/integration/cors.py +30 -0
  20. deployforge/integration/environment.py +62 -0
  21. deployforge/integration/frontend_backend.py +39 -0
  22. deployforge/providers/__init__.py +32 -0
  23. deployforge/providers/base.py +151 -0
  24. deployforge/providers/render.py +218 -0
  25. deployforge/providers/vercel.py +205 -0
  26. deployforge/security/__init__.py +4 -0
  27. deployforge/security/gitignore.py +35 -0
  28. deployforge/security/scanner.py +125 -0
  29. deployforge/security/secrets.py +110 -0
  30. deployforge/ui/__init__.py +1 -0
  31. deployforge/ui/terminal.py +151 -0
  32. deployforge-0.1.0.dist-info/METADATA +218 -0
  33. deployforge-0.1.0.dist-info/RECORD +37 -0
  34. deployforge-0.1.0.dist-info/WHEEL +5 -0
  35. deployforge-0.1.0.dist-info/entry_points.txt +2 -0
  36. deployforge-0.1.0.dist-info/licenses/LICENSE +21 -0
  37. deployforge-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,151 @@
1
+ """Deployment provider abstraction.
2
+
3
+ All cloud integrations go through :class:`DeploymentProvider`. Every backend
4
+ operation is performed through :class:`HttpClient` so the network layer can be
5
+ mocked cleanly in tests. Providers raise :class:`ProviderError` on failures.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ import requests
16
+
17
+ from deployforge.errors import AuthenticationError, ProviderError
18
+
19
+ DEFAULT_TIMEOUT = 30.0
20
+
21
+
22
+ class HttpClient:
23
+ """Thin wrapper around ``requests`` with typed error handling."""
24
+
25
+ def __init__(self, timeout: float = DEFAULT_TIMEOUT) -> None:
26
+ self.timeout = timeout
27
+
28
+ def request(self, method: str, url: str, **kwargs: Any) -> Any:
29
+ """Perform an HTTP request and return the parsed JSON body.
30
+
31
+ Raises :class:`ProviderError` for HTTP errors and transport failures.
32
+ """
33
+ try:
34
+ response = requests.request(method, url, timeout=self.timeout, **kwargs)
35
+ except requests.RequestException as exc:
36
+ raise ProviderError(f"Network error while contacting {url}: {exc}") from exc
37
+
38
+ if response.status_code in (401, 403):
39
+ raise AuthenticationError(
40
+ f"Authentication failed for {url.split('/')[2]}. "
41
+ "Check your API token or run 'deployforge doctor'."
42
+ )
43
+ if response.status_code >= 400:
44
+ detail = _error_detail(response)
45
+ message = f"HTTP {response.status_code} from {url}: {detail}"
46
+ raise ProviderError(message, status_code=response.status_code)
47
+ if response.status_code == 204 or not response.text:
48
+ return None
49
+ try:
50
+ return response.json()
51
+ except ValueError as exc:
52
+ raise ProviderError(f"Invalid JSON response from {url}") from exc
53
+
54
+
55
+ def _error_detail(response: requests.Response) -> str:
56
+ try:
57
+ data = response.json()
58
+ except ValueError:
59
+ return response.text[:300]
60
+ if isinstance(data, dict):
61
+ message = data.get("message") or data.get("error") or data.get("detail")
62
+ if isinstance(message, str) and message:
63
+ return message[:300]
64
+ return response.text[:300]
65
+
66
+
67
+ @dataclass
68
+ class AuthInfo:
69
+ authenticated: bool
70
+ provider: str
71
+ account: str | None = None
72
+ source: str = "env"
73
+
74
+ def describe(self) -> str:
75
+ if self.authenticated:
76
+ return f"{self.provider} authenticated"
77
+ return f"{self.provider} not authenticated"
78
+
79
+
80
+ @dataclass
81
+ class DeploymentResult:
82
+ provider: str
83
+ project_name: str
84
+ service_id: str
85
+ url: str | None = None
86
+ status: str = "created"
87
+ extra: dict[str, Any] = field(default_factory=dict)
88
+
89
+ def to_dict(self) -> dict[str, Any]:
90
+ return {
91
+ "provider": self.provider,
92
+ "project_name": self.project_name,
93
+ "service_id": self.service_id,
94
+ "url": self.url,
95
+ "status": self.status,
96
+ }
97
+
98
+
99
+ @dataclass
100
+ class DeployStatus:
101
+ provider: str
102
+ state: str
103
+ url: str | None
104
+ message: str | None = None
105
+
106
+
107
+ class DeploymentProvider(ABC):
108
+ """Interface every deployment provider must implement."""
109
+
110
+ name: str = "abstract"
111
+
112
+ def __init__(self, client: HttpClient | None = None) -> None:
113
+ self.http = client or HttpClient()
114
+
115
+ @abstractmethod
116
+ def authenticate(self) -> AuthInfo: ...
117
+
118
+ @abstractmethod
119
+ def create_project(self, *, name: str, **kwargs: Any) -> DeploymentResult: ...
120
+
121
+ @abstractmethod
122
+ def deploy(self, project: DeploymentResult, *, ref: str | None = None) -> DeploymentResult: ...
123
+
124
+ @abstractmethod
125
+ def get_status(self, project: DeploymentResult) -> DeployStatus: ...
126
+
127
+ @abstractmethod
128
+ def get_url(self, project: DeploymentResult) -> str | None: ...
129
+
130
+ @abstractmethod
131
+ def get_logs(self, project: DeploymentResult) -> list[str]: ...
132
+
133
+ def set_env(self, project: DeploymentResult, key: str, value: str) -> None:
134
+ raise ProviderError(f"{self.name} does not support setting environment variables.")
135
+
136
+ def poll_until_ready(
137
+ self,
138
+ project: DeploymentResult,
139
+ timeout: int = 900,
140
+ interval: float = 8.0,
141
+ on_progress: Any | None = None,
142
+ ) -> DeployStatus:
143
+ deadline = time.monotonic() + timeout
144
+ while time.monotonic() < deadline:
145
+ status = self.get_status(project)
146
+ if on_progress is not None:
147
+ on_progress(status)
148
+ if status.state in {"ready", "error", "canceled"}:
149
+ return status
150
+ time.sleep(interval)
151
+ return self.get_status(project)
@@ -0,0 +1,218 @@
1
+ """Render deployment provider.
2
+
3
+ Uses the documented Render REST API (https://api.render.com). Web services
4
+ are created and polled through official endpoints. Deploys with ``autoDeploy``
5
+ so future GitHub pushes redeploy automatically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from deployforge.errors import ProviderError
13
+ from deployforge.providers.base import (
14
+ AuthInfo,
15
+ DeploymentProvider,
16
+ DeploymentResult,
17
+ DeployStatus,
18
+ HttpClient,
19
+ )
20
+
21
+ API_BASE = "https://api.render.com/v1"
22
+
23
+ RENDER_ENV_MAP = {
24
+ "python": "python3",
25
+ "node": "node",
26
+ }
27
+
28
+ _LIVE_STATES = {"Live", "live"}
29
+ _BUILD_STATES = {"Build in progress", "Provisioning", "Pending", "Scheduled", "Deploying"}
30
+ _ERROR_STATES = {"Deploy failed", "Build failed", "Canceled", "Canceled and rollback"}
31
+
32
+
33
+ class RenderProvider(DeploymentProvider):
34
+ name = "render"
35
+
36
+ def __init__(self, api_key: str | None, client: HttpClient | None = None) -> None:
37
+ super().__init__(client)
38
+ self.api_key = api_key
39
+ self._owner_id: str | None = None
40
+
41
+ def _headers(self) -> dict[str, str]:
42
+ if not self.api_key:
43
+ raise ProviderError(
44
+ "Render authentication is missing. Set RENDER_API_KEY or run 'deployforge config'."
45
+ )
46
+ return {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json"}
47
+
48
+ def _get_owner_id(self) -> str:
49
+ if self._owner_id:
50
+ return self._owner_id
51
+ data = self.http.request("GET", f"{API_BASE}/owners", headers=self._headers())
52
+ if not isinstance(data, list) or not data:
53
+ raise ProviderError("Render returned no owners; check your API key.")
54
+ owner = data[0]
55
+ if not isinstance(owner, dict) or not owner.get("id"):
56
+ raise ProviderError("Render returned an unexpected owner object.")
57
+ self._owner_id = str(owner["id"])
58
+ return self._owner_id
59
+
60
+ def authenticate(self) -> AuthInfo:
61
+ if not self.api_key:
62
+ return AuthInfo(authenticated=False, provider="render")
63
+ try:
64
+ data = self.http.request("GET", f"{API_BASE}/owners", headers=self._headers())
65
+ except ProviderError:
66
+ return AuthInfo(authenticated=False, provider="render")
67
+ if isinstance(data, list) and data:
68
+ account = None
69
+ owner = data[0]
70
+ if isinstance(owner, dict) and isinstance(owner.get("name"), str):
71
+ account = owner["name"]
72
+ return AuthInfo(authenticated=True, provider="render", account=account)
73
+ return AuthInfo(authenticated=False, provider="render")
74
+
75
+ def create_project(
76
+ self,
77
+ *,
78
+ name: str,
79
+ repo: str | None = None,
80
+ runtime: str | None = None,
81
+ root_directory: str | None = None,
82
+ build_command: str | None = None,
83
+ start_command: str | None = None,
84
+ plan: str = "starter",
85
+ branch: str = "main",
86
+ health_check_path: str | None = None,
87
+ **kwargs: Any,
88
+ ) -> DeploymentResult:
89
+ if not repo or not runtime:
90
+ raise ProviderError("Render requires a repository and runtime to deploy.")
91
+ owner_id = self._get_owner_id()
92
+ payload: dict[str, Any] = {
93
+ "type": "web_service",
94
+ "name": name,
95
+ "env": RENDER_ENV_MAP.get(runtime, runtime),
96
+ "plan": plan,
97
+ "repo": repo,
98
+ "branch": branch,
99
+ "autoDeploy": True,
100
+ }
101
+ if root_directory and root_directory not in (".", ""):
102
+ payload["rootDir"] = root_directory
103
+ if build_command:
104
+ payload["buildCommand"] = build_command
105
+ if start_command:
106
+ payload["startCommand"] = start_command
107
+ if health_check_path:
108
+ payload["healthCheckPath"] = health_check_path
109
+ data = self.http.request(
110
+ "POST",
111
+ f"{API_BASE}/services?ownerId={owner_id}",
112
+ headers=self._headers(),
113
+ json=payload,
114
+ )
115
+ if not isinstance(data, dict) or not data.get("id"):
116
+ raise ProviderError("Render did not return a service id.")
117
+ service_id = str(data["id"])
118
+ url = None
119
+ details = data.get("serviceDetails")
120
+ if isinstance(details, dict) and isinstance(details.get("url"), str):
121
+ url = details["url"]
122
+ return DeploymentResult(
123
+ provider="render",
124
+ project_name=str(data.get("name") or name),
125
+ service_id=service_id,
126
+ url=url,
127
+ status="created",
128
+ extra={"owner_id": owner_id},
129
+ )
130
+
131
+ def set_env(self, project: DeploymentResult, key: str, value: str) -> None:
132
+ payload = {"envVars": [{"key": key, "value": value}]}
133
+ self.http.request(
134
+ "PUT",
135
+ f"{API_BASE}/services/{project.service_id}/env-vars",
136
+ headers=self._headers(),
137
+ json=payload,
138
+ )
139
+
140
+ def deploy(self, project: DeploymentResult, *, ref: str | None = None) -> DeploymentResult:
141
+ # Render deploys automatically on push when autoDeploy is enabled.
142
+ # Pushing the branch or triggering via service is handled by Render.
143
+ status = self.get_status(project)
144
+ return DeploymentResult(
145
+ provider="render",
146
+ project_name=project.project_name,
147
+ service_id=project.service_id,
148
+ url=status.url,
149
+ status=status.state,
150
+ extra=project.extra,
151
+ )
152
+
153
+ def get_status(self, project: DeploymentResult) -> DeployStatus:
154
+ data = self.http.request(
155
+ "GET",
156
+ f"{API_BASE}/services/{project.service_id}",
157
+ headers=self._headers(),
158
+ )
159
+ if not isinstance(data, dict):
160
+ raise ProviderError("Render returned an unexpected service response.")
161
+ url = None
162
+ details = data.get("serviceDetails")
163
+ if isinstance(details, dict) and isinstance(details.get("url"), str):
164
+ url = details["url"]
165
+
166
+ deploys = self.http.request(
167
+ "GET",
168
+ f"{API_BASE}/services/{project.service_id}/deploys",
169
+ headers=self._headers(),
170
+ )
171
+ latest_status = "Live"
172
+ if isinstance(deploys, list) and deploys:
173
+ first = deploys[0]
174
+ if isinstance(first, dict):
175
+ latest_status = str(first.get("status") or "Live")
176
+
177
+ if latest_status in _LIVE_STATES:
178
+ return DeployStatus(provider="render", state="ready", url=url)
179
+ if latest_status in _ERROR_STATES:
180
+ message = None
181
+ if isinstance(deploys, list) and deploys and isinstance(deploys[0], dict):
182
+ message = (
183
+ deploys[0].get("commit", {}).get("message")
184
+ if isinstance(deploys[0].get("commit"), dict)
185
+ else None
186
+ )
187
+ return DeployStatus(
188
+ provider="render", state="error", url=url, message=message or latest_status
189
+ )
190
+ return DeployStatus(provider="render", state="building", url=url)
191
+
192
+ def get_url(self, project: DeploymentResult) -> str | None:
193
+ return self.get_status(project).url
194
+
195
+ def get_logs(self, project: DeploymentResult) -> list[str]:
196
+ deploys = self.http.request(
197
+ "GET",
198
+ f"{API_BASE}/services/{project.service_id}/deploys",
199
+ headers=self._headers(),
200
+ )
201
+ logs: list[str] = []
202
+ if not isinstance(deploys, list):
203
+ return logs
204
+ for deploy in deploys[:10]:
205
+ if not isinstance(deploy, dict):
206
+ continue
207
+ commit = deploy.get("commit")
208
+ message = None
209
+ if isinstance(commit, dict):
210
+ message = commit.get("message")
211
+ ref = commit.get("id", "unknown")[:9]
212
+ else:
213
+ ref = "n/a"
214
+ line = f"[{deploy.get('status')}] ref {ref}"
215
+ if isinstance(message, str) and message:
216
+ line += f" — {message[:80]}"
217
+ logs.append(line)
218
+ return logs
@@ -0,0 +1,205 @@
1
+ """Vercel deployment provider.
2
+
3
+ Uses the documented Vercel REST API (https://api.vercel.com). Frameworks and
4
+ git repositories are connected through official project/deployment endpoints.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from deployforge.errors import ProviderError
12
+ from deployforge.providers.base import (
13
+ AuthInfo,
14
+ DeploymentProvider,
15
+ DeploymentResult,
16
+ DeployStatus,
17
+ HttpClient,
18
+ )
19
+
20
+ API_BASE = "https://api.vercel.com"
21
+
22
+ # Vercel preset names for supported frameworks.
23
+ FRAMEWORK_PRESETS: dict[str, str | None] = {
24
+ "Next.js": "nextjs",
25
+ "React": "create-react-app",
26
+ "Vite": "vite",
27
+ "Vue": "vue",
28
+ "Nuxt": "nuxtjs",
29
+ "Angular": "angular",
30
+ "Astro": "astro",
31
+ "Svelte": "svelte",
32
+ "SvelteKit": "sveltekit",
33
+ "Static HTML": None,
34
+ }
35
+
36
+ _STATE_READY = {"READY"}
37
+ _STATE_ERROR = {"ERROR", "ERROR_BUILD", "ERROR_DEPLOYMENT", "ERROR_INSPECTOR_BLOCKED"}
38
+
39
+
40
+ class VercelProvider(DeploymentProvider):
41
+ name = "vercel"
42
+
43
+ def __init__(self, token: str | None, client: HttpClient | None = None) -> None:
44
+ super().__init__(client)
45
+ self.token = token
46
+
47
+ # -- HTTP helpers -------------------------------------------------------
48
+
49
+ def _headers(self) -> dict[str, str]:
50
+ if not self.token:
51
+ raise ProviderError(
52
+ "Vercel authentication is missing. Set VERCEL_TOKEN or run 'deployforge config'."
53
+ )
54
+ return {
55
+ "Authorization": f"Bearer {self.token}",
56
+ "Accept": "application/json",
57
+ }
58
+
59
+ # -- DeploymentProvider -------------------------------------------------
60
+
61
+ def authenticate(self) -> AuthInfo:
62
+ if not self.token:
63
+ return AuthInfo(authenticated=False, provider="vercel")
64
+ data = self.http.request("GET", f"{API_BASE}/v2/user", headers=self._headers())
65
+ user = data.get("user") if isinstance(data, dict) else None
66
+ account = user.get("username") if isinstance(user, dict) else None
67
+ return AuthInfo(authenticated=True, provider="vercel", account=account)
68
+
69
+ def create_project(
70
+ self,
71
+ *,
72
+ name: str,
73
+ repo: str | None = None,
74
+ directory: str | None = None,
75
+ framework: str | None = None,
76
+ private: bool = True,
77
+ **kwargs: Any,
78
+ ) -> DeploymentResult:
79
+ if not repo:
80
+ raise ProviderError("Vercel requires a GitHub repository to deploy.")
81
+ payload: dict[str, Any] = {
82
+ "name": name,
83
+ "gitRepository": {"type": "github", "repo": repo},
84
+ "public": not private,
85
+ }
86
+ preset = FRAMEWORK_PRESETS.get(framework or "", framework)
87
+ if preset:
88
+ payload["framework"] = preset
89
+ if directory and directory not in (".", ""):
90
+ payload["rootDirectory"] = directory
91
+
92
+ data = self.http.request(
93
+ "POST",
94
+ f"{API_BASE}/v10/projects",
95
+ headers=self._headers(),
96
+ json=payload,
97
+ )
98
+ if not isinstance(data, dict):
99
+ msg = "Vercel returned an unexpected response while creating the project."
100
+ raise ProviderError(msg)
101
+ project_id = str(data.get("id") or "")
102
+ project_name = str(data.get("name") or name)
103
+ if not project_id:
104
+ raise ProviderError("Vercel did not return a project id.")
105
+ return DeploymentResult(
106
+ provider="vercel",
107
+ project_name=project_name,
108
+ service_id=project_id,
109
+ status="created",
110
+ extra={"framework": framework},
111
+ )
112
+
113
+ def set_env(self, project: DeploymentResult, key: str, value: str) -> None:
114
+ payload = {
115
+ "key": key,
116
+ "value": value,
117
+ "target": ["production", "preview", "development"],
118
+ "type": "encrypted",
119
+ }
120
+ self.http.request(
121
+ "POST",
122
+ f"{API_BASE}/v10/projects/{project.service_id}/env",
123
+ headers=self._headers(),
124
+ json=payload,
125
+ )
126
+
127
+ def deploy(self, project: DeploymentResult, *, ref: str | None = None) -> DeploymentResult:
128
+ payload: dict[str, Any] = {
129
+ "name": project.project_name,
130
+ "target": "production",
131
+ "gitSource": {"type": "github", "ref": ref or "main"},
132
+ }
133
+ try:
134
+ data = self.http.request(
135
+ "POST",
136
+ f"{API_BASE}/v13/deployments",
137
+ headers=self._headers(),
138
+ json=payload,
139
+ )
140
+ except ProviderError:
141
+ # With a git-connected project, a push triggers deployment
142
+ # automatically; we re-check the project for an active deployment.
143
+ return project
144
+ if not isinstance(data, dict):
145
+ return project
146
+ deployment_id = str(data.get("id") or "") or project.service_id
147
+ return DeploymentResult(
148
+ provider="vercel",
149
+ project_name=project.project_name,
150
+ service_id=deployment_id,
151
+ status="building",
152
+ extra=project.extra,
153
+ )
154
+
155
+ def get_status(self, project: DeploymentResult) -> DeployStatus:
156
+ data = self.http.request(
157
+ "GET",
158
+ f"{API_BASE}/v13/deployments/{project.service_id}",
159
+ headers=self._headers(),
160
+ )
161
+ if not isinstance(data, dict):
162
+ raise ProviderError("Vercel returned an unexpected deployment status.")
163
+ state = str(data.get("readyState") or data.get("state") or "QUEUED")
164
+ if state in _STATE_READY:
165
+ url = self._url_from_deployment(data)
166
+ return DeployStatus(provider="vercel", state="ready", url=url)
167
+ if state in _STATE_ERROR:
168
+ return DeployStatus(
169
+ provider="vercel",
170
+ state="error",
171
+ url=None,
172
+ message=f"Vercel deployment failed ({state}).",
173
+ )
174
+ return DeployStatus(provider="vercel", state="building", url=None)
175
+
176
+ @staticmethod
177
+ def _url_from_deployment(data: dict[str, Any]) -> str | None:
178
+ aliases = data.get("alias")
179
+ if isinstance(aliases, list) and aliases and isinstance(aliases[0], str):
180
+ return f"https://{aliases[0]}"
181
+ url = data.get("url")
182
+ if isinstance(url, str) and url:
183
+ return f"https://{url}"
184
+ return None
185
+
186
+ def get_url(self, project: DeploymentResult) -> str | None:
187
+ status = self.get_status(project)
188
+ return status.url
189
+
190
+ def get_logs(self, project: DeploymentResult) -> list[str]:
191
+ data = self.http.request(
192
+ "GET",
193
+ f"{API_BASE}/v13/deployments/{project.service_id}",
194
+ headers=self._headers(),
195
+ )
196
+ if not isinstance(data, dict):
197
+ return []
198
+ logs: list[str] = []
199
+ if data.get("createdAt"):
200
+ logs.append(f"Deployment created (id {project.service_id})")
201
+ if data.get("readyState"):
202
+ logs.append(f"State: {data['readyState']}")
203
+ if data.get("errorMessage"):
204
+ logs.append(f"Error: {data['errorMessage']}")
205
+ return logs
@@ -0,0 +1,4 @@
1
+ from deployforge.security.scanner import SecurityReport, scan_project
2
+ from deployforge.security.secrets import Finding, fingerprint
3
+
4
+ __all__ = ["Finding", "SecurityReport", "fingerprint", "scan_project"]
@@ -0,0 +1,35 @@
1
+ """Gitignore validation.
2
+
3
+ Checks that a project's ``.gitignore`` covers the most sensitive file kinds so
4
+ the security gate can report drift before a deployment.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ REQUIRED_RULES = (
12
+ ".env*",
13
+ "*.pem",
14
+ "*.key",
15
+ "credentials.json",
16
+ "service-account.json",
17
+ )
18
+
19
+
20
+ def validate_gitignore(root: Path) -> tuple[bool, list[str]]:
21
+ """Return (ok, missing_rules) for the project's ``.gitignore``."""
22
+ gitignore = root / ".gitignore"
23
+ if not gitignore.exists():
24
+ return True, []
25
+ try:
26
+ lines = [line.strip() for line in gitignore.read_text(encoding="utf-8").splitlines()]
27
+ except OSError:
28
+ return True, []
29
+ present = {line for line in lines if line and not line.startswith("#")}
30
+ missing = [rule for rule in REQUIRED_RULES if not any(_matches(rule, line) for line in present)]
31
+ return not missing, missing
32
+
33
+
34
+ def _matches(rule: str, line: str) -> bool:
35
+ return line.endswith(rule) or line == rule