1"""Who am I, and which database is mine?
2
3Everything here is derived — from the directory, from git, from pyproject —
4never from a registry. The only stored state is the pointer file, and it exists
5only when a checkout has been deliberately repointed with `plain db use`.
6"""
7
8from __future__ import annotations
9
10import os
11import re
12import subprocess
13import tomllib
14from dataclasses import dataclass
15from functools import cache
16from pathlib import Path
17
18from ..state import checkout_id, checkout_state_path, sanitize, short_digest
19
20MAX_NAME_LENGTH = 63 # Postgres identifier limit
21
22# Local dev only — the cluster is bound to localhost and holds throwaway data.
23DEV_USER = "postgres"
24DEV_PASSWORD = "postgres"
25
26DEFAULT_POSTGRES_IMAGE = "postgres:16"
27
28
29def truncate_identifier(name: str) -> str:
30 """Keep `name` inside Postgres' 63-char limit, hash-suffixing if truncated."""
31 if len(name) <= MAX_NAME_LENGTH:
32 return name
33 return name[: MAX_NAME_LENGTH - 9] + "_" + short_digest(name)
34
35
36@cache
37def _git_common_dir(cwd: Path) -> str | None:
38 """The shared git dir — identical for every worktree of a repo.
39
40 Cached (along with `_git_toplevel`) because the answer can't change within
41 one process, and a single cold `plain dev` start otherwise asks git the
42 same question five times — cluster name, volume name, database name, and
43 checkout label all derive from these two values.
44 """
45 return _run_git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd)
46
47
48def _run_git(args: list[str], cwd: Path) -> str | None:
49 """Run git in `cwd`, ignoring any repository the environment points at.
50
51 Git sets `GIT_DIR` (and friends) for hooks, so a plain command invoked from
52 one — `plain pre-commit` is itself a hook — would otherwise resolve against
53 the hook's repository instead of the directory we asked about. With
54 `GIT_DIR` set and no work tree, `rev-parse --show-toplevel` simply fails,
55 which would quietly drop us back to a less precise answer.
56 """
57 env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
58 try:
59 out = subprocess.run(
60 ["git", *args],
61 cwd=cwd,
62 capture_output=True,
63 text=True,
64 check=True,
65 env=env,
66 ).stdout.strip()
67 return out or None
68 except (subprocess.CalledProcessError, FileNotFoundError):
69 return None
70
71
72@cache
73def _git_toplevel(cwd: Path) -> Path | None:
74 """The root of *this* worktree — differs for every checkout of a repo."""
75 out = _run_git(["rev-parse", "--show-toplevel"], cwd)
76 return Path(out) if out else None
77
78
79def current_branch(project_root: Path) -> str | None:
80 # Deliberately not cached: a long-lived `plain dev` can outlive a branch.
81 return _run_git(["rev-parse", "--abbrev-ref", "HEAD"], project_root)
82
83
84def checkout_label(project_root: Path) -> str | None:
85 """What distinguishes this checkout from the project's other ones.
86
87 `None` means "this is the main checkout" (or not a git repo at all), so the
88 caller should use the project name unqualified.
89
90 Anchored on the git worktree root rather than the app directory, because an
91 app often *isn't* the checkout root — `example/`, `src/`, `backend/`. Those
92 directories are named identically in every worktree, so deriving from them
93 would hand two checkouts the same database and let them overwrite each
94 other's data. The worktree root is the thing that's actually per-checkout.
95 """
96 toplevel = _git_toplevel(project_root)
97 if toplevel is None:
98 return None
99
100 # The main worktree is the one whose root holds the shared git dir.
101 common_dir = _git_common_dir(project_root)
102 if common_dir and Path(common_dir).parent.resolve() == toplevel.resolve():
103 return None
104
105 return sanitize(toplevel.name)
106
107
108# What we're willing to put in a database name. Postgres itself is far more
109# permissive — a quoted identifier can hold almost anything — but the name also
110# has to survive being interpolated into a connection URL, where a `/` or `?`
111# silently turns into a different database or a bogus query parameter. So the
112# limit here is the URL's, not Postgres'.
113VALID_DATABASE_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_$]*$")
114
115
116class InvalidDatabaseName(ValueError):
117 """A name that can't be used as-is in both SQL and a connection URL."""
118
119
120def validate_database_name(name: str) -> str:
121 """Return `name` unchanged, or raise `InvalidDatabaseName` explaining why not.
122
123 Checked at the boundary — every command that accepts a name from a person —
124 because the failure otherwise lands far away and hard: `plain db use
125 'foo?x=1'` wrote a pointer that made every subsequent command fail while
126 parsing the URL, including the `plain db use` that would have undone it.
127 """
128 if not name:
129 raise InvalidDatabaseName("Database name is empty.")
130 if len(name) > MAX_NAME_LENGTH:
131 raise InvalidDatabaseName(
132 f"Database name {name!r} is {len(name)} characters; "
133 f"Postgres allows at most {MAX_NAME_LENGTH}."
134 )
135 if not VALID_DATABASE_NAME_RE.match(name):
136 raise InvalidDatabaseName(
137 f"Database name {name!r} must start with a letter or underscore and "
138 "contain only letters, digits, underscores, or '$'."
139 )
140 return name
141
142
143@cache # The file can't change within one process; several call sites read it.
144def read_pyproject(project_root: Path) -> dict:
145 pyproject = project_root / "pyproject.toml"
146 if not pyproject.exists():
147 return {}
148 with open(pyproject, "rb") as f:
149 return tomllib.load(f)
150
151
152@dataclass(frozen=True)
153class PostgresConfig:
154 """`[tool.plain.dev.postgres]` from pyproject.toml."""
155
156 backend: str = "auto" # auto | docker | local | off
157 image: str = DEFAULT_POSTGRES_IMAGE
158
159 @classmethod
160 def load(cls, project_root: Path) -> PostgresConfig:
161 section = (
162 read_pyproject(project_root)
163 .get("tool", {})
164 .get("plain", {})
165 .get("dev", {})
166 .get("postgres", {})
167 )
168 return cls(
169 backend=str(section.get("backend", "auto")),
170 image=str(section.get("image", DEFAULT_POSTGRES_IMAGE)),
171 )
172
173
174def project_identity(project_root: Path) -> tuple[str, str]:
175 """Return `(name, hash)` identifying the project across all its worktrees.
176
177 The hash anchors on git-common-dir, which is identical for every worktree of
178 a repo. That is load-bearing, not tidiness: `CREATE DATABASE … TEMPLATE`
179 only works within a single cluster, so every worktree that wants to fork
180 from main must land in the same cluster.
181 """
182 name = project_root.name
183 if project_name := read_pyproject(project_root).get("project", {}).get("name"):
184 name = project_name
185 name = sanitize(name)
186 anchor = _git_common_dir(project_root) or checkout_id(project_root)
187 return name, short_digest(anchor)
188
189
190def cluster_name(project_root: Path) -> str:
191 """Docker container / local-cluster identity for this project."""
192 name, project_hash = project_identity(project_root)
193 return f"plain-postgres-{name}-{project_hash}"
194
195
196def volume_name(project_root: Path) -> str:
197 return cluster_name(project_root) + "-data"
198
199
200def database_name_for_checkout(project_name: str, *, checkout: Path) -> str:
201 """The database a given checkout directory owns by default.
202
203 The main checkout gets the project name as-is, so it reads naturally and
204 becomes the fork source for everything else. Other worktrees get
205 `{project}_{worktree}` — unless the worktree is already named after the
206 project, as `git worktree add ../myapp-feature` produces, in which case it's
207 namespaced enough on its own and we don't stutter it into
208 `myapp_myapp_feature`.
209 """
210 label = checkout_label(checkout)
211 if label is None:
212 # Main checkout, or no git at all — fall back to the directory itself.
213 label = sanitize(checkout.name)
214
215 if label == project_name or label.startswith(f"{project_name}_"):
216 return truncate_identifier(label)
217 return truncate_identifier(f"{project_name}_{label}")
218
219
220def pointer_path(project_root: Path) -> Path:
221 return checkout_state_path(project_root) / "database"
222
223
224def read_pointer(project_root: Path) -> str | None:
225 """The explicitly-assigned database for this checkout, if any.
226
227 Presence is meaningful: it means someone ran `plain db use`. Absence means
228 "derive from the directory", which is the common case.
229 """
230 pointer = pointer_path(project_root)
231 if not pointer.exists():
232 return None
233 return pointer.read_text().strip() or None
234
235
236def write_pointer(project_root: Path, *, db_name: str) -> None:
237 pointer = pointer_path(project_root)
238 pointer.parent.mkdir(parents=True, exist_ok=True)
239 pointer.write_text(db_name)
240
241
242def clear_pointer(project_root: Path) -> None:
243 pointer_path(project_root).unlink(missing_ok=True)
244
245
246def resolve_database_name(project_root: Path) -> str:
247 """Pointer override, else derived from the directory."""
248 if pointed := read_pointer(project_root):
249 return pointed
250 project_name, _ = project_identity(project_root)
251 return database_name_for_checkout(project_name, checkout=project_root)