v0.154.0
 1"""Which checkout am I, and where does its state live?
 2
 3A checkout accumulates facts about itself: which database it uses, whether its
 4dev server is running. Those facts are *about* the checkout but they don't
 5belong *inside* it — a working tree is exactly what gets symlinked, copied,
 6rsynced, and mounted into containers, so state keyed by location is eventually
 7read by the wrong reader. Two worktrees sharing a `.plain/` then quietly share
 8a database, or one refuses to start because the other's dev server holds the
 9pidfile.
10
11So the checkout's path is the key, and the state lives beside the rest of
12Plain's machine-level cache. Artifacts stay in `.plain/` — logs you tail,
13compiled assets, certificates. The difference is that an artifact read from the
14wrong place is confusing, while a *fact* read from the wrong place is wrong.
15"""
16
17from __future__ import annotations
18
19import hashlib
20from pathlib import Path
21
22from plain.runtime import PLAIN_CACHE_PATH
23
24from .utils import has_pyproject_toml
25
26
27def sanitize(value: str) -> str:
28    """Lowercase and reduce to `[a-z0-9_]`."""
29    return "".join(c if c.isalnum() else "_" for c in value.lower()).strip("_")
30
31
32def short_digest(value: str) -> str:
33    """A short, stable hash for disambiguating names built from `value`."""
34    return hashlib.sha256(value.encode()).hexdigest()[:8]
35
36
37def find_project_root(start: Path) -> Path:
38    """The nearest directory at or above `start` holding a pyproject.toml.
39
40    One definition, used by the CLI (which starts from the app), by `setup()`
41    (which starts from the working directory), and by the dev supervisors,
42    because they have to agree: the project root decides the database name, the
43    cluster identity, and where this checkout's state lives. Two answers means
44    two checkouts.
45    """
46    for directory in [start, *start.parents]:
47        if has_pyproject_toml(directory):
48            return directory
49    return start
50
51
52def checkout_id(project_root: Path) -> str:
53    """What "this checkout" means when we record or compare ownership.
54
55    One definition because it's compared for exact equality against the
56    database metadata `plain.dev.postgres.guard` reads, and two sites
57    normalizing differently would silently disagree forever rather than fail.
58    """
59    return str(project_root.resolve())
60
61
62def checkout_state_path(project_root: Path) -> Path:
63    """Where this checkout's facts about itself are kept.
64
65    Keyed by the checkout's resolved path (see the module docstring for why),
66    with the readable checkout name in the directory too, so the cache stays
67    greppable when something needs explaining.
68    """
69    resolved = project_root.resolve()
70    return (
71        PLAIN_CACHE_PATH
72        / "checkouts"
73        / f"{sanitize(resolved.name)}-{short_digest(str(resolved))}"
74    )