1"""What `plain db clean` may drop, and why it leaves the rest.
2
3Dropping a database can't be undone, and `plain db clean` drops databases
4nobody named. So what it may drop is decided in one function of facts,
5`plan_clean`, which touches no database and no disk, and everything it is
6given is listed with the reason: what would be dropped, and what is left.
7
8**A development database** is dropped only when all of this is so:
9
10- It isn't the project's main database, or this checkout's.
11- No checkout of the project is configured to use it. This goes by what each
12 checkout uses now, not by who the database's metadata says made it: a
13 worktree that was moved, or pointed at it with `plain db use`, uses it
14 whatever the metadata says.
15- Its metadata names the checkout that made it, and that checkout is gone.
16- Nothing is connected to it.
17
18**When a checkout is gone.** Its directory is missing, and:
19
20- in a git repository, git lists no worktree that holds it. A worktree git
21 still lists whose directory is missing may be on a volume that isn't
22 mounted, so its database is left until `git worktree prune` says
23 otherwise.
24- outside git, the directory that held it is still there. When that is
25 missing too, nothing can be told from the path.
26
27**A test database** is dropped only when it carries the record of the run
28that made it and that run is proved dead. **A template**, which a
29database's test runs clone, is dropped only when its record says it is of
30no use now: the schema of this checkout's database has changed since it
31was built, the database it was built for is gone from the server, or the
32run building it died before it was done. `plain.postgres.testing.leftovers`
33has both rules, and this module only reports its verdicts.
34
35Outside a git repository the only checkout known is the one the command
36runs from, so "no checkout is configured to use it" can only be checked for
37that one.
38"""
39
40from dataclasses import dataclass
41from pathlib import Path, PurePath
42from typing import TYPE_CHECKING
43
44from .identity import _run_git, resolve_database_name
45
46if TYPE_CHECKING:
47 from plain.postgres.testing.leftovers import LeftoverVerdict
48
49 from .cluster import Cluster, DevDatabase
50
51
52@dataclass(frozen=True)
53class Checkout:
54 """A checkout of the project: a git worktree's copy of the project root."""
55
56 path: str
57 # The worktree it is in, as git lists it. Outside git, the same as `path`.
58 worktree: str
59 # Its directory is there. A worktree git lists can be missing: removed
60 # by hand and not pruned, or on a volume that isn't mounted.
61 exists: bool
62 # The database it is configured to use. `None` when it is missing.
63 database: str | None
64
65
66@dataclass(frozen=True)
67class OwnerPath:
68 """What is on disk where a database's metadata says its checkout was."""
69
70 exists: bool
71 parent_exists: bool
72
73
74@dataclass(frozen=True)
75class CleanFacts:
76 """Everything `plan_clean` goes by, about one database."""
77
78 name: str
79 size_bytes: int
80 # The checkout its metadata names, and what is there now.
81 recorded_checkout: str | None
82 owner_path: OwnerPath | None
83 connections: int
84 is_test: bool
85 # For a test database: whether its run is proved dead, and the reason.
86 # Both from `plain.postgres.testing.leftovers.judge_leftover`. For a
87 # template (a test database with `is_template` too): whether it is of
88 # no use now, and why, from `judge_template`.
89 run_is_dead: bool = False
90 run_verdict: str = ""
91 is_template: bool = False
92 # For a template: whether the database it was built for is gone from
93 # the server. Dropping it then needs the same fact.
94 tested_database_is_gone: bool = False
95
96
97@dataclass(frozen=True)
98class CleanItem:
99 name: str
100 size_bytes: int
101 owner: str
102 reason: str
103 is_test: bool
104 is_template: bool = False
105 tested_database_is_gone: bool = False
106
107
108@dataclass(frozen=True)
109class CleanPlan:
110 drop: tuple[CleanItem, ...]
111 leave: tuple[CleanItem, ...]
112
113
114def plan_clean(
115 databases: list[CleanFacts],
116 *,
117 project_main: str,
118 current: str,
119 checkouts: list[Checkout],
120 in_git: bool,
121) -> CleanPlan:
122 """Sort every database into what to drop and what to leave, with why."""
123 drop = []
124 leave = []
125 for facts in databases:
126 dropping, reason = _judge(
127 facts,
128 project_main=project_main,
129 current=current,
130 checkouts=checkouts,
131 in_git=in_git,
132 )
133 item = CleanItem(
134 name=facts.name,
135 size_bytes=facts.size_bytes,
136 owner=_owner(facts),
137 reason=reason,
138 is_test=facts.is_test,
139 is_template=facts.is_template,
140 tested_database_is_gone=facts.tested_database_is_gone,
141 )
142 (drop if dropping else leave).append(item)
143 return CleanPlan(drop=tuple(drop), leave=tuple(leave))
144
145
146def _owner(facts: CleanFacts) -> str:
147 if facts.is_template:
148 return "(test template)"
149 if facts.is_test:
150 return "(test database)"
151 return facts.recorded_checkout or "(no recorded owner)"
152
153
154def _judge(
155 facts: CleanFacts,
156 *,
157 project_main: str,
158 current: str,
159 checkouts: list[Checkout],
160 in_git: bool,
161) -> tuple[bool, str]:
162 if facts.name == project_main:
163 return False, "the project's main database, which every checkout forks from"
164 if facts.name == current:
165 return False, "this checkout's database"
166
167 for checkout in checkouts:
168 if checkout.database == facts.name:
169 return False, f"the checkout at {checkout.path} is configured to use it"
170
171 if facts.is_test:
172 return facts.run_is_dead, facts.run_verdict
173
174 if not facts.recorded_checkout or facts.owner_path is None:
175 return False, "no record of which checkout made it"
176 if facts.owner_path.exists:
177 return False, "its checkout is there"
178
179 if in_git:
180 holder = _worktree_holding(facts.recorded_checkout, checkouts=checkouts)
181 if holder is not None:
182 return False, (
183 f"git lists a worktree at {holder.worktree}, though its directory"
184 " is missing (a volume that isn't mounted, or a worktree"
185 " removed by hand: `git worktree prune` settles it)"
186 )
187 elif not facts.owner_path.parent_exists:
188 return False, (
189 "its checkout's directory is missing and so is the directory"
190 " that held it, so whether the checkout is gone can't be told"
191 )
192
193 if facts.connections:
194 count = facts.connections
195 open_to_it = "1 connection is" if count == 1 else f"{count} connections are"
196 return False, f"{open_to_it} open to it, though its checkout is gone"
197
198 return True, "its checkout is gone"
199
200
201def _worktree_holding(path: str, *, checkouts: list[Checkout]) -> Checkout | None:
202 """The checkout whose worktree git lists and `path` is in, if its
203 directory is missing."""
204 for checkout in checkouts:
205 if checkout.exists:
206 continue
207 if PurePath(path).is_relative_to(PurePath(checkout.worktree)):
208 return checkout
209 return None
210
211
212# ---------------------------------------------------------------------------
213# Reading the facts
214# ---------------------------------------------------------------------------
215
216
217def look_at_owner_path(recorded_checkout: str | None) -> OwnerPath | None:
218 if not recorded_checkout:
219 return None
220 path = Path(recorded_checkout)
221 return OwnerPath(exists=path.exists(), parent_exists=path.parent.exists())
222
223
224def project_checkouts(project_root: Path) -> tuple[list[Checkout], bool]:
225 """
226 The project's checkouts, and whether the project is in a git
227 repository.
228
229 In git, the repository's worktrees are the checkouts: for each one git
230 lists, the directory in it where this project's root is. Outside git,
231 the only checkout known is `project_root`.
232 """
233 toplevel = _run_git(["rev-parse", "--show-toplevel"], project_root)
234 listing = _run_git(["worktree", "list", "--porcelain"], project_root)
235 if toplevel is None or listing is None:
236 return [_checkout_at(project_root, worktree=project_root)], False
237
238 # Where the project's root is inside a worktree: `example`, or nowhere
239 # deeper than the worktree itself.
240 inside = project_root.resolve().relative_to(Path(toplevel).resolve())
241
242 checkouts = []
243 for line in listing.splitlines():
244 if line.startswith("worktree "):
245 worktree = Path(line.removeprefix("worktree "))
246 checkouts.append(_checkout_at(worktree / inside, worktree=worktree))
247 return checkouts, True
248
249
250def _checkout_at(root: Path, *, worktree: Path) -> Checkout:
251 # Resolved the way a checkout's path is when it is recorded, so the two
252 # compare. For a missing directory that settles what of it is there.
253 if not root.is_dir():
254 return Checkout(
255 path=str(root.resolve()),
256 worktree=str(worktree.resolve()),
257 exists=False,
258 database=None,
259 )
260 return Checkout(
261 path=str(root.resolve()),
262 worktree=str(worktree.resolve()),
263 exists=True,
264 database=resolve_database_name(root.resolve()),
265 )
266
267
268def read_clean_facts(
269 cluster: Cluster,
270 *,
271 project_name: str,
272 current: str,
273 current_schema: str | None,
274) -> list[CleanFacts]:
275 """The facts about every database of the project. Reads, and changes
276 nothing: a test database's run lock is asked for and given straight back.
277
278 `current_schema` is the digest of the schema this checkout's database,
279 `current`, builds its test databases with now, which is what tells its
280 template from a stale one. `None` when it couldn't be worked out, and
281 then the template is left.
282 """
283 from plain.postgres.testing.leftovers import (
284 judge_leftover,
285 judge_template,
286 look_at_leftover,
287 look_at_template,
288 maintenance_connection,
289 )
290
291 databases = cluster.list_databases(project_name)
292 names = {database.name for database in databases}
293 facts = []
294 with maintenance_connection(cluster.config) as maintenance:
295 for database in databases:
296 if database.template_record is not None:
297 template = database.template_record
298 gone = template.database not in names
299 facts.append(
300 _test_database_facts(
301 database,
302 verdict=judge_template(
303 look_at_template(
304 maintenance,
305 cluster.config,
306 name=database.name,
307 record=template,
308 ),
309 current_schema=current_schema
310 if template.database == current
311 else None,
312 tested_database_is_gone=gone,
313 ),
314 is_template=True,
315 tested_database_is_gone=gone,
316 )
317 )
318 elif database.is_test:
319 facts.append(
320 _test_database_facts(
321 database,
322 verdict=judge_leftover(
323 look_at_leftover(
324 maintenance,
325 cluster.config,
326 name=database.name,
327 record=database.run_record,
328 ),
329 tested_database=_tested_database(database),
330 ),
331 )
332 )
333 else:
334 facts.append(
335 CleanFacts(
336 name=database.name,
337 size_bytes=database.size_bytes,
338 recorded_checkout=database.checkout,
339 owner_path=look_at_owner_path(database.checkout),
340 connections=cluster.connection_count(database.name),
341 is_test=False,
342 )
343 )
344 return facts
345
346
347def _tested_database(database: DevDatabase) -> str:
348 return database.run_record.database if database.run_record else ""
349
350
351def _test_database_facts(
352 database: DevDatabase,
353 *,
354 verdict: LeftoverVerdict,
355 is_template: bool = False,
356 tested_database_is_gone: bool = False,
357) -> CleanFacts:
358 reason = verdict.reason
359 if database.run_record is None and database.template_record is None:
360 reason += f" (if it is debris: plain db drop {database.name})"
361 return CleanFacts(
362 name=database.name,
363 size_bytes=database.size_bytes,
364 recorded_checkout=None,
365 owner_path=None,
366 connections=0,
367 is_test=True,
368 run_is_dead=verdict.drop,
369 run_verdict=reason,
370 is_template=is_template,
371 tested_database_is_gone=tested_database_is_gone,
372 )