v0.154.0
  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)